diff --git a/src/channels/plugins/legacy-config.test.ts b/src/channels/plugins/legacy-config.test.ts index d2afd0f0a18..bd7f98c432c 100644 --- a/src/channels/plugins/legacy-config.test.ts +++ b/src/channels/plugins/legacy-config.test.ts @@ -105,4 +105,34 @@ describe("collectChannelLegacyConfigRules", () => { pluginIds: ["custom-chat"], }); }); + + it("scopes channel legacy scans to touched channels during dry-run validation", () => { + loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => ({ + legacyConfigRules: [ + { + path: ["channels", channelId], + message: `legacy ${channelId} rule`, + }, + ], + })); + + const rules = collectChannelLegacyConfigRules( + { + channels: { + discord: {}, + telegram: {}, + }, + }, + [["channels", "discord", "token"]], + ); + + expect(rules).toEqual([ + { + path: ["channels", "discord"], + message: "legacy discord rule", + }, + ]); + expect(loadBundledChannelDoctorContractApiMock).toHaveBeenCalledTimes(1); + expect(loadBundledChannelDoctorContractApiMock).toHaveBeenCalledWith("discord"); + }); }); diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index 92bfa60e173..884c6c6fb84 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -17,8 +17,59 @@ function collectConfiguredChannelIds(raw: unknown): ChannelId[] { .map((channelId) => channelId as ChannelId); } -export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule[] { - const channelIds = collectConfiguredChannelIds(raw); +function shouldIncludeLegacyRuleForTouchedPaths( + rulePath: readonly string[], + touchedPaths?: ReadonlyArray>, +): boolean { + if (!touchedPaths || touchedPaths.length === 0) { + return true; + } + return touchedPaths.some((touchedPath) => { + const sharedLength = Math.min(rulePath.length, touchedPath.length); + for (let index = 0; index < sharedLength; index += 1) { + if (rulePath[index] !== touchedPath[index]) { + return false; + } + } + return true; + }); +} + +function collectRelevantChannelIdsForTouchedPaths(params: { + raw?: unknown; + touchedPaths?: ReadonlyArray>; +}): ChannelId[] { + const channelIds = collectConfiguredChannelIds(params.raw); + if (!params.touchedPaths || params.touchedPaths.length === 0) { + return channelIds; + } + + const touchedChannelIds = new Set(); + for (const touchedPath of params.touchedPaths) { + const [first, second] = touchedPath; + if (first !== "channels") { + continue; + } + if (!second) { + return channelIds; + } + if (second === "defaults") { + continue; + } + touchedChannelIds.add(second as ChannelId); + } + + if (touchedChannelIds.size === 0) { + return []; + } + return channelIds.filter((channelId) => touchedChannelIds.has(channelId)); +} + +export function collectChannelLegacyConfigRules( + raw?: unknown, + touchedPaths?: ReadonlyArray>, +): LegacyConfigRule[] { + const channelIds = collectRelevantChannelIdsForTouchedPaths({ raw, touchedPaths }); const rules: LegacyConfigRule[] = []; const unresolvedChannelIds: ChannelId[] = []; for (const channelId of channelIds) { @@ -42,6 +93,9 @@ export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule const seen = new Set(); return rules.filter((rule) => { + if (!shouldIncludeLegacyRuleForTouchedPaths(rule.path, touchedPaths)) { + return false; + } const key = `${rule.path.join(".")}::${rule.message}`; if (seen.has(key)) { return false; diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 04f75dd2ab4..b08200ca6f7 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -921,8 +921,13 @@ function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInD return { refsToResolve, skippedExecRefs }; } -function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { - const validated = validateConfigObjectRaw(config); +function collectDryRunSchemaErrors(params: { + config: OpenClawConfig; + operations: ReadonlyArray; +}): ConfigSetDryRunError[] { + const validated = validateConfigObjectRaw(params.config, { + touchedPaths: params.operations.map((operation) => operation.setPath), + }); if (validated.ok) { return []; } @@ -1062,7 +1067,12 @@ export async function runConfigSet(opts: { ); } if (requiresFullSchemaValidation) { - errors.push(...collectDryRunSchemaErrors(nextConfig)); + errors.push( + ...collectDryRunSchemaErrors({ + config: nextConfig, + operations, + }), + ); } if (hasJsonMode || hasBuilderMode) { errors.push( diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 0664d307880..79bac2bd033 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -18,6 +18,7 @@ export function findLegacyConfigIssues( raw: unknown, sourceRaw?: unknown, extraRules: LegacyConfigRule[] = [], + touchedPaths?: ReadonlyArray>, ): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") { return []; @@ -28,7 +29,7 @@ export function findLegacyConfigIssues( const issues: LegacyConfigIssue[] = []; for (const rule of [ ...LEGACY_CONFIG_RULES, - ...collectChannelLegacyConfigRules(raw), + ...collectChannelLegacyConfigRules(raw, touchedPaths), ...extraRules, ]) { const cursor = getPathValue(root, rule.path); diff --git a/src/config/validation.ts b/src/config/validation.ts index 05f5266c24c..13a18d987eb 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -9,6 +9,7 @@ import { } from "../plugins/config-state.js"; import { collectRelevantDoctorPluginIds, + collectRelevantDoctorPluginIdsForTouchedPaths, listPluginDoctorLegacyConfigRules, } from "../plugins/doctor-contract-registry.js"; import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js"; @@ -571,12 +572,21 @@ function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationI */ export function validateConfigObjectRaw( raw: unknown, + opts?: { + touchedPaths?: ReadonlyArray>; + }, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw); + const doctorPluginIds = opts?.touchedPaths + ? collectRelevantDoctorPluginIdsForTouchedPaths({ + raw, + touchedPaths: opts.touchedPaths, + }) + : collectRelevantDoctorPluginIds(raw); const extraLegacyRules = listPluginDoctorLegacyConfigRules({ - pluginIds: collectRelevantDoctorPluginIds(raw), + pluginIds: doctorPluginIds, }); - const legacyIssues = findLegacyConfigIssues(raw, raw, extraLegacyRules); + const legacyIssues = findLegacyConfigIssues(raw, raw, extraLegacyRules, opts?.touchedPaths); if (legacyIssues.length > 0) { return { ok: false, diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 3e3662e05a7..f8a22fc11c7 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -11,6 +11,7 @@ const tempDirs: string[] = []; const mocks = getRegistryJitiMocks(); let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache; +let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths; let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules; function makeTempDir(): string { @@ -25,8 +26,11 @@ describe("doctor-contract-registry getJiti", () => { beforeEach(async () => { resetRegistryJitiMocks(); vi.resetModules(); - ({ clearPluginDoctorContractRegistryCache, listPluginDoctorLegacyConfigRules } = - await import("./doctor-contract-registry.js")); + ({ + clearPluginDoctorContractRegistryCache, + collectRelevantDoctorPluginIdsForTouchedPaths, + listPluginDoctorLegacyConfigRules, + } = await import("./doctor-contract-registry.js")); clearPluginDoctorContractRegistryCache(); }); @@ -56,4 +60,49 @@ describe("doctor-contract-registry getJiti", () => { }), ); }); + + it("narrows touched-path doctor ids for scoped dry-run validation", () => { + expect( + collectRelevantDoctorPluginIdsForTouchedPaths({ + raw: { + channels: { + discord: {}, + telegram: {}, + }, + plugins: { + entries: { + "memory-wiki": {}, + }, + }, + talk: { + voiceId: "legacy-voice", + }, + }, + touchedPaths: [ + ["channels", "discord", "token"], + ["plugins", "entries", "memory-wiki", "enabled"], + ["talk", "voiceId"], + ], + }), + ).toEqual(["discord", "elevenlabs", "memory-wiki"]); + }); + + it("falls back to the full doctor-id set when touched paths are too broad", () => { + expect( + collectRelevantDoctorPluginIdsForTouchedPaths({ + raw: { + channels: { + discord: {}, + telegram: {}, + }, + plugins: { + entries: { + "memory-wiki": {}, + }, + }, + }, + touchedPaths: [["channels"]], + }), + ).toEqual(["discord", "memory-wiki", "telegram"]); + }); }); diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index b631b703ea0..c928634cec0 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -161,6 +161,42 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] { return [...ids].toSorted(); } +export function collectRelevantDoctorPluginIdsForTouchedPaths(params: { + raw: unknown; + touchedPaths: ReadonlyArray>; +}): string[] { + const root = asNullableRecord(params.raw); + if (!root) { + return []; + } + + const ids = new Set(); + for (const touchedPath of params.touchedPaths) { + const [first, second, third] = touchedPath; + if (first === "channels") { + if (!second) { + return collectRelevantDoctorPluginIds(params.raw); + } + if (second !== "defaults") { + ids.add(second); + } + continue; + } + if (first === "plugins") { + if (second !== "entries" || !third) { + return collectRelevantDoctorPluginIds(params.raw); + } + ids.add(third); + continue; + } + if (first === "talk" && hasLegacyElevenLabsTalkFields(root)) { + ids.add("elevenlabs"); + } + } + + return [...ids].toSorted(); +} + function getDoctorContractRecordCache( baseCacheKey: string, ): Map {