perf(config): scope dry-run legacy validation

This commit is contained in:
Vincent Koc
2026-04-13 20:40:52 +01:00
parent e02c6ca82a
commit 25a2ea4480
7 changed files with 200 additions and 10 deletions

View File

@@ -105,4 +105,34 @@ describe("collectChannelLegacyConfigRules", () => {
pluginIds: ["custom-chat"], 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");
});
}); });

View File

@@ -17,8 +17,59 @@ function collectConfiguredChannelIds(raw: unknown): ChannelId[] {
.map((channelId) => channelId as ChannelId); .map((channelId) => channelId as ChannelId);
} }
export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule[] { function shouldIncludeLegacyRuleForTouchedPaths(
const channelIds = collectConfiguredChannelIds(raw); rulePath: readonly string[],
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
): 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<ReadonlyArray<string>>;
}): ChannelId[] {
const channelIds = collectConfiguredChannelIds(params.raw);
if (!params.touchedPaths || params.touchedPaths.length === 0) {
return channelIds;
}
const touchedChannelIds = new Set<ChannelId>();
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<ReadonlyArray<string>>,
): LegacyConfigRule[] {
const channelIds = collectRelevantChannelIdsForTouchedPaths({ raw, touchedPaths });
const rules: LegacyConfigRule[] = []; const rules: LegacyConfigRule[] = [];
const unresolvedChannelIds: ChannelId[] = []; const unresolvedChannelIds: ChannelId[] = [];
for (const channelId of channelIds) { for (const channelId of channelIds) {
@@ -42,6 +93,9 @@ export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule
const seen = new Set<string>(); const seen = new Set<string>();
return rules.filter((rule) => { return rules.filter((rule) => {
if (!shouldIncludeLegacyRuleForTouchedPaths(rule.path, touchedPaths)) {
return false;
}
const key = `${rule.path.join(".")}::${rule.message}`; const key = `${rule.path.join(".")}::${rule.message}`;
if (seen.has(key)) { if (seen.has(key)) {
return false; return false;

View File

@@ -921,8 +921,13 @@ function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInD
return { refsToResolve, skippedExecRefs }; return { refsToResolve, skippedExecRefs };
} }
function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { function collectDryRunSchemaErrors(params: {
const validated = validateConfigObjectRaw(config); config: OpenClawConfig;
operations: ReadonlyArray<ConfigSetOperation>;
}): ConfigSetDryRunError[] {
const validated = validateConfigObjectRaw(params.config, {
touchedPaths: params.operations.map((operation) => operation.setPath),
});
if (validated.ok) { if (validated.ok) {
return []; return [];
} }
@@ -1062,7 +1067,12 @@ export async function runConfigSet(opts: {
); );
} }
if (requiresFullSchemaValidation) { if (requiresFullSchemaValidation) {
errors.push(...collectDryRunSchemaErrors(nextConfig)); errors.push(
...collectDryRunSchemaErrors({
config: nextConfig,
operations,
}),
);
} }
if (hasJsonMode || hasBuilderMode) { if (hasJsonMode || hasBuilderMode) {
errors.push( errors.push(

View File

@@ -18,6 +18,7 @@ export function findLegacyConfigIssues(
raw: unknown, raw: unknown,
sourceRaw?: unknown, sourceRaw?: unknown,
extraRules: LegacyConfigRule[] = [], extraRules: LegacyConfigRule[] = [],
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
): LegacyConfigIssue[] { ): LegacyConfigIssue[] {
if (!raw || typeof raw !== "object") { if (!raw || typeof raw !== "object") {
return []; return [];
@@ -28,7 +29,7 @@ export function findLegacyConfigIssues(
const issues: LegacyConfigIssue[] = []; const issues: LegacyConfigIssue[] = [];
for (const rule of [ for (const rule of [
...LEGACY_CONFIG_RULES, ...LEGACY_CONFIG_RULES,
...collectChannelLegacyConfigRules(raw), ...collectChannelLegacyConfigRules(raw, touchedPaths),
...extraRules, ...extraRules,
]) { ]) {
const cursor = getPathValue(root, rule.path); const cursor = getPathValue(root, rule.path);

View File

@@ -9,6 +9,7 @@ import {
} from "../plugins/config-state.js"; } from "../plugins/config-state.js";
import { import {
collectRelevantDoctorPluginIds, collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules, listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js"; } from "../plugins/doctor-contract-registry.js";
import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js"; import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js";
@@ -571,12 +572,21 @@ function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationI
*/ */
export function validateConfigObjectRaw( export function validateConfigObjectRaw(
raw: unknown, raw: unknown,
opts?: {
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
},
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw); const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw);
const doctorPluginIds = opts?.touchedPaths
? collectRelevantDoctorPluginIdsForTouchedPaths({
raw,
touchedPaths: opts.touchedPaths,
})
: collectRelevantDoctorPluginIds(raw);
const extraLegacyRules = listPluginDoctorLegacyConfigRules({ 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) { if (legacyIssues.length > 0) {
return { return {
ok: false, ok: false,

View File

@@ -11,6 +11,7 @@ const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks(); const mocks = getRegistryJitiMocks();
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache; 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; let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
function makeTempDir(): string { function makeTempDir(): string {
@@ -25,8 +26,11 @@ describe("doctor-contract-registry getJiti", () => {
beforeEach(async () => { beforeEach(async () => {
resetRegistryJitiMocks(); resetRegistryJitiMocks();
vi.resetModules(); vi.resetModules();
({ clearPluginDoctorContractRegistryCache, listPluginDoctorLegacyConfigRules } = ({
await import("./doctor-contract-registry.js")); clearPluginDoctorContractRegistryCache,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
} = await import("./doctor-contract-registry.js"));
clearPluginDoctorContractRegistryCache(); 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"]);
});
}); });

View File

@@ -161,6 +161,42 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
return [...ids].toSorted(); return [...ids].toSorted();
} }
export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
raw: unknown;
touchedPaths: ReadonlyArray<ReadonlyArray<string>>;
}): string[] {
const root = asNullableRecord(params.raw);
if (!root) {
return [];
}
const ids = new Set<string>();
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( function getDoctorContractRecordCache(
baseCacheKey: string, baseCacheKey: string,
): Map<string, PluginDoctorContractEntry | null> { ): Map<string, PluginDoctorContractEntry | null> {