Skip to content

Commit 9238d9a

Browse files
authored
Expose legacy plugin manifest doctor lint findings (#98695)
Expose default-disabled Doctor lint findings for legacy plugin manifest contract migrations. - Maps existing legacy manifest contract diagnostics into structured HealthFinding output. - Keeps real manifest rewriting in the existing legacy doctor --fix path; no structured dry-run repair hook in this PR. - Default doctor --lint remains unchanged; explicit --only core/doctor/legacy-plugin-manifests and --all can select it. Validation: - Hosted PR checks green on head 2a01993. - node scripts/run-vitest.mjs src/commands/doctor-plugin-manifests.test.ts src/flows/doctor-health-contributions.test.ts (63 passed) - changed-file pnpm exec oxfmt --check - changed-file pnpm exec oxlint - node scripts/plugin-sdk-surface-report.mjs --check - git diff --check
1 parent dce1b0a commit 9238d9a

4 files changed

Lines changed: 149 additions & 5 deletions

File tree

src/commands/doctor-plugin-manifests.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { RuntimeEnv } from "../runtime.js";
77
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
88
import {
99
collectLegacyPluginManifestContractMigrations,
10+
legacyPluginManifestContractMigrationToHealthFinding,
1011
maybeRepairLegacyPluginManifestContracts,
1112
} from "./doctor-plugin-manifests.js";
1213
import type { DoctorPrompter } from "./doctor-prompter.js";
@@ -169,6 +170,40 @@ describe("doctor plugin manifest legacy contract repair", () => {
169170
]);
170171
});
171172

173+
it("maps legacy manifest migrations to structured health findings", async () => {
174+
const pluginsRoot = await suiteTempDirs.make("finding-capability");
175+
const root = path.join(pluginsRoot, "openai");
176+
fs.mkdirSync(root, { recursive: true });
177+
writePackageJson(root);
178+
writeManifest(root, {
179+
id: "openai",
180+
speechProviders: ["openai"],
181+
configSchema: { type: "object" },
182+
});
183+
184+
const [migration] = collectLegacyPluginManifestContractMigrations({
185+
config: configWithPluginLoadPath(pluginsRoot),
186+
env: {
187+
...process.env,
188+
},
189+
manifestRoots: [pluginsRoot],
190+
});
191+
192+
if (migration === undefined) {
193+
throw new Error("expected legacy manifest migration");
194+
}
195+
expect(legacyPluginManifestContractMigrationToHealthFinding(migration)).toStrictEqual({
196+
checkId: "core/doctor/legacy-plugin-manifests",
197+
severity: "warning",
198+
message: "Plugin manifest openai uses legacy top-level capability keys.",
199+
path: path.join(root, "openclaw.plugin.json"),
200+
target: "openai",
201+
requirement: "contracts-capability-keys",
202+
fixHint:
203+
"Run `openclaw doctor --fix` to rewrite legacy plugin manifest capability keys under contracts.*.",
204+
});
205+
});
206+
172207
it("rewrites legacy top-level capability keys into contracts", async () => {
173208
const pluginsRoot = await suiteTempDirs.make("rewrite-capability");
174209
const root = path.join(pluginsRoot, "openai");

src/commands/doctor-plugin-manifests.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-
66
import { z } from "zod";
77
import { note } from "../../packages/terminal-core/src/note.js";
88
import type { OpenClawConfig } from "../config/types.openclaw.js";
9+
import type { HealthFinding } from "../flows/health-checks.js";
910
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
1011
import type { RuntimeEnv } from "../runtime.js";
1112
import { shortenHomePath } from "../utils.js";
@@ -18,6 +19,7 @@ const LEGACY_MANIFEST_CONTRACT_KEYS = [
1819
"imageGenerationProviders",
1920
"tools",
2021
] as const;
22+
const LEGACY_PLUGIN_MANIFESTS_CHECK_ID = "core/doctor/legacy-plugin-manifests";
2123

2224
type LegacyManifestContractMigration = {
2325
manifestPath: string;
@@ -152,6 +154,25 @@ export function collectLegacyPluginManifestContractMigrations(params?: {
152154
return migrations.toSorted((left, right) => left.manifestPath.localeCompare(right.manifestPath));
153155
}
154156

157+
export function legacyPluginManifestContractMigrationToHealthFinding(
158+
migration: LegacyManifestContractMigration,
159+
): HealthFinding {
160+
return {
161+
checkId: LEGACY_PLUGIN_MANIFESTS_CHECK_ID,
162+
severity: "warning",
163+
message: `Plugin manifest ${migration.pluginId} uses legacy top-level capability keys.`,
164+
path: migration.manifestPath,
165+
target: migration.pluginId,
166+
requirement: "contracts-capability-keys",
167+
fixHint:
168+
"Run `openclaw doctor --fix` to rewrite legacy plugin manifest capability keys under contracts.*.",
169+
};
170+
}
171+
172+
function migrationToManifestJson(migration: LegacyManifestContractMigration): string {
173+
return `${JSON.stringify(migration.nextRaw, null, 2)}\n`;
174+
}
175+
155176
/** Prompts and rewrites legacy plugin manifest contract fields when doctor repair is enabled. */
156177
export async function maybeRepairLegacyPluginManifestContracts(params: {
157178
config?: OpenClawConfig;
@@ -194,11 +215,7 @@ export async function maybeRepairLegacyPluginManifestContracts(params: {
194215
const applied: string[] = [];
195216
for (const migration of migrations) {
196217
try {
197-
fs.writeFileSync(
198-
migration.manifestPath,
199-
`${JSON.stringify(migration.nextRaw, null, 2)}\n`,
200-
"utf-8",
201-
);
218+
fs.writeFileSync(migration.manifestPath, migrationToManifestJson(migration), "utf-8");
202219
applied.push(...migration.changeLines);
203220
} catch (error) {
204221
params.runtime.error(

src/flows/doctor-health-contributions.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ const mocks = vi.hoisted(() => ({
5757
noteChromeMcpBrowserReadiness: vi.fn(),
5858
detectLegacyStateMigrations: vi.fn(),
5959
runLegacyStateMigrations: vi.fn(),
60+
collectLegacyPluginManifestContractMigrations: vi.fn(() => [] as unknown[]),
61+
legacyPluginManifestContractMigrationToHealthFinding: vi.fn(
62+
(migration: { pluginId: string }) => ({
63+
checkId: "core/doctor/legacy-plugin-manifests",
64+
severity: "warning" as const,
65+
message: `Plugin manifest ${migration.pluginId} uses legacy top-level capability keys.`,
66+
path: "/tmp/openclaw-plugin/openclaw.plugin.json",
67+
target: migration.pluginId,
68+
requirement: "contracts-capability-keys",
69+
}),
70+
),
71+
maybeRepairLegacyPluginManifestContracts: vi.fn().mockResolvedValue(undefined),
6072
detectLegacyClawdBrowserProfileResidue: vi.fn(),
6173
maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(),
6274
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"),
@@ -167,6 +179,14 @@ vi.mock("../commands/doctor-state-migrations.js", () => ({
167179
runLegacyStateMigrations: mocks.runLegacyStateMigrations,
168180
}));
169181

182+
vi.mock("../commands/doctor-plugin-manifests.js", () => ({
183+
collectLegacyPluginManifestContractMigrations:
184+
mocks.collectLegacyPluginManifestContractMigrations,
185+
legacyPluginManifestContractMigrationToHealthFinding:
186+
mocks.legacyPluginManifestContractMigrationToHealthFinding,
187+
maybeRepairLegacyPluginManifestContracts: mocks.maybeRepairLegacyPluginManifestContracts,
188+
}));
189+
170190
vi.mock("../commands/doctor-auth-oauth-sidecar.js", () => ({
171191
maybeRepairLegacyOAuthSidecarProfiles: mocks.maybeRepairLegacyOAuthSidecarProfiles,
172192
}));
@@ -384,6 +404,11 @@ describe("doctor health contributions", () => {
384404
mocks.maybeRepairGatewayDaemon.mockResolvedValue(undefined);
385405
mocks.maybeRepairLegacyOAuthProfileIds.mockClear();
386406
mocks.maybeRepairLegacyOAuthProfileIds.mockImplementation(async (cfg: unknown) => cfg);
407+
mocks.collectLegacyPluginManifestContractMigrations.mockReset();
408+
mocks.collectLegacyPluginManifestContractMigrations.mockReturnValue([]);
409+
mocks.legacyPluginManifestContractMigrationToHealthFinding.mockClear();
410+
mocks.maybeRepairLegacyPluginManifestContracts.mockClear();
411+
mocks.maybeRepairLegacyPluginManifestContracts.mockResolvedValue(undefined);
387412
mocks.maybeRepairLegacyOAuthSidecarProfiles.mockClear();
388413
mocks.maybeRepairLegacyOAuthSidecarProfiles.mockResolvedValue(undefined);
389414
mocks.collectAuthProfileHealthFindings.mockClear();
@@ -520,6 +545,58 @@ describe("doctor health contributions", () => {
520545
vi.restoreAllMocks();
521546
});
522547

548+
it("keeps legacy plugin manifest lint opt-in for structured findings", async () => {
549+
const contribution = requireDoctorContribution("doctor:legacy-plugin-manifests");
550+
const check = contribution.healthChecks[0] as HealthCheck & { defaultEnabled?: boolean };
551+
expect(contribution.healthCheckIds).toEqual(["core/doctor/legacy-plugin-manifests"]);
552+
expect(check.defaultEnabled).toBe(false);
553+
554+
const migration = {
555+
manifestPath: "/tmp/openclaw-plugin/openclaw.plugin.json",
556+
pluginId: "legacy-plugin",
557+
nextRaw: {},
558+
changeLines: ["- moved tools to contracts.tools"],
559+
};
560+
mocks.collectLegacyPluginManifestContractMigrations.mockReturnValueOnce([migration]);
561+
const ctx = {
562+
cfg: { plugins: { load: { paths: ["/tmp/openclaw-plugin"] } } },
563+
mode: "lint" as const,
564+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
565+
};
566+
567+
await expect(runDoctorLintChecks(ctx, { checks: [check] })).resolves.toMatchObject({
568+
checksRun: 0,
569+
checksSkipped: 1,
570+
});
571+
expect(mocks.collectLegacyPluginManifestContractMigrations).not.toHaveBeenCalled();
572+
573+
await expect(
574+
runDoctorLintChecks(ctx, {
575+
checks: [check],
576+
onlyIds: ["core/doctor/legacy-plugin-manifests"],
577+
}),
578+
).resolves.toMatchObject({
579+
checksRun: 1,
580+
checksSkipped: 0,
581+
findings: [
582+
expect.objectContaining({
583+
checkId: "core/doctor/legacy-plugin-manifests",
584+
target: "legacy-plugin",
585+
requirement: "contracts-capability-keys",
586+
}),
587+
],
588+
});
589+
expect(mocks.collectLegacyPluginManifestContractMigrations).toHaveBeenCalledWith({
590+
config: ctx.cfg,
591+
env: process.env,
592+
});
593+
expect(mocks.legacyPluginManifestContractMigrationToHealthFinding).toHaveBeenCalledWith(
594+
migration,
595+
expect.any(Number),
596+
expect.any(Array),
597+
);
598+
});
599+
523600
it("runs release configured plugin install repair before plugin registry and final config writes", () => {
524601
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
525602

src/flows/doctor-health-contributions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,21 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
14261426
createDoctorHealthContribution({
14271427
id: "doctor:legacy-plugin-manifests",
14281428
label: "Legacy plugin manifests",
1429+
healthChecks: {
1430+
id: "core/doctor/legacy-plugin-manifests",
1431+
description: "Legacy plugin manifest capability keys are reported as findings.",
1432+
defaultEnabled: false,
1433+
async detect(ctx) {
1434+
const {
1435+
collectLegacyPluginManifestContractMigrations,
1436+
legacyPluginManifestContractMigrationToHealthFinding,
1437+
} = await import("../commands/doctor-plugin-manifests.js");
1438+
return collectLegacyPluginManifestContractMigrations({
1439+
config: ctx.cfg,
1440+
env: process.env,
1441+
}).map(legacyPluginManifestContractMigrationToHealthFinding);
1442+
},
1443+
},
14291444
run: runLegacyPluginManifestHealth,
14301445
}),
14311446
createDoctorHealthContribution({

0 commit comments

Comments
 (0)