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

View File

@@ -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<ConfigSetOperation>;
}): 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(

View File

@@ -18,6 +18,7 @@ export function findLegacyConfigIssues(
raw: unknown,
sourceRaw?: unknown,
extraRules: LegacyConfigRule[] = [],
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
): 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);

View File

@@ -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<ReadonlyArray<string>>;
},
): { 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,

View File

@@ -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"]);
});
});

View File

@@ -161,6 +161,42 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
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(
baseCacheKey: string,
): Map<string, PluginDoctorContractEntry | null> {