mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
perf(config): scope dry-run legacy validation
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user