diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 4ff03af91e0..396634cb088 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -7,6 +7,7 @@ import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { FIELD_HELP } from "./schema.help.js"; import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; +import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -132,24 +133,6 @@ function asSchemaObject(value: unknown): JsonSchemaObject | null { return value as JsonSchemaObject; } -function schemaHasChildren(schema: JsonSchemaObject): boolean { - if (schema.properties && Object.keys(schema.properties).length > 0) { - return true; - } - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - return true; - } - if (Array.isArray(schema.items)) { - return schema.items.some((entry) => typeof entry === "object" && entry !== null); - } - for (const branch of [schema.oneOf, schema.anyOf, schema.allOf]) { - if (branch?.some((entry) => entry && typeof entry === "object" && schemaHasChildren(entry))) { - return true; - } - } - return Boolean(schema.items && typeof schema.items === "object"); -} - function splitHintLookupPath(path: string): string[] { const normalized = normalizeBaselinePath(path); return normalized ? normalized.split(".").filter(Boolean) : []; @@ -159,45 +142,11 @@ function resolveUiHintMatch( uiHints: ConfigSchemaResponse["uiHints"], path: string, ): ConfigSchemaResponse["uiHints"][string] | undefined { - const targetParts = splitHintLookupPath(path); - let bestMatch: - | { - hint: ConfigSchemaResponse["uiHints"][string]; - wildcardCount: number; - } - | undefined; - - for (const [hintPath, hint] of Object.entries(uiHints)) { - const hintParts = splitHintLookupPath(hintPath); - if (hintParts.length !== targetParts.length) { - continue; - } - - let wildcardCount = 0; - let matches = true; - for (let index = 0; index < hintParts.length; index += 1) { - const hintPart = hintParts[index]; - const targetPart = targetParts[index]; - if (hintPart === targetPart) { - continue; - } - if (hintPart === "*") { - wildcardCount += 1; - continue; - } - matches = false; - break; - } - - if (!matches) { - continue; - } - if (!bestMatch || wildcardCount < bestMatch.wildcardCount) { - bestMatch = { hint, wildcardCount }; - } - } - - return bestMatch?.hint; + return findWildcardHintMatch({ + uiHints, + path, + splitPath: splitHintLookupPath, + })?.hint; } function normalizeTypeValue(value: string | string[] | undefined): string | string[] | undefined { diff --git a/src/config/schema.shared.test.ts b/src/config/schema.shared.test.ts new file mode 100644 index 00000000000..48820fbf029 --- /dev/null +++ b/src/config/schema.shared.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; + +describe("schema.shared", () => { + it("prefers the most specific wildcard hint match", () => { + const match = findWildcardHintMatch({ + uiHints: { + "channels.*.token": { label: "wildcard" }, + "channels.telegram.token": { label: "telegram" }, + }, + path: "channels.telegram.token", + splitPath: (value) => value.split("."), + }); + + expect(match).toEqual({ + path: "channels.telegram.token", + hint: { label: "telegram" }, + }); + }); + + it("treats branch schemas as having children", () => { + expect( + schemaHasChildren({ + oneOf: [{ type: "string" }, { properties: { token: { type: "string" } } }], + }), + ).toBe(true); + }); +}); diff --git a/src/config/schema.shared.ts b/src/config/schema.shared.ts new file mode 100644 index 00000000000..148d5b3fb86 --- /dev/null +++ b/src/config/schema.shared.ts @@ -0,0 +1,73 @@ +type JsonSchemaObject = { + properties?: Record; + additionalProperties?: JsonSchemaObject | boolean; + items?: JsonSchemaObject | JsonSchemaObject[]; + anyOf?: JsonSchemaObject[]; + allOf?: JsonSchemaObject[]; + oneOf?: JsonSchemaObject[]; +}; + +export function schemaHasChildren(schema: JsonSchemaObject): boolean { + if (schema.properties && Object.keys(schema.properties).length > 0) { + return true; + } + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + return true; + } + if (Array.isArray(schema.items)) { + return schema.items.some((entry) => typeof entry === "object" && entry !== null); + } + for (const branch of [schema.oneOf, schema.anyOf, schema.allOf]) { + if (branch?.some((entry) => entry && typeof entry === "object" && schemaHasChildren(entry))) { + return true; + } + } + return Boolean(schema.items && typeof schema.items === "object"); +} + +export function findWildcardHintMatch(params: { + uiHints: Record; + path: string; + splitPath: (path: string) => string[]; +}): { path: string; hint: T } | null { + const targetParts = params.splitPath(params.path); + let bestMatch: + | { + path: string; + hint: T; + wildcardCount: number; + } + | undefined; + + for (const [hintPath, hint] of Object.entries(params.uiHints)) { + const hintParts = params.splitPath(hintPath); + if (hintParts.length !== targetParts.length) { + continue; + } + + let wildcardCount = 0; + let matches = true; + for (let index = 0; index < hintParts.length; index += 1) { + const hintPart = hintParts[index]; + const targetPart = targetParts[index]; + if (hintPart === targetPart) { + continue; + } + if (hintPart === "*") { + wildcardCount += 1; + continue; + } + matches = false; + break; + } + + if (!matches) { + continue; + } + if (!bestMatch || wildcardCount < bestMatch.wildcardCount) { + bestMatch = { path: hintPath, hint, wildcardCount }; + } + } + + return bestMatch ? { path: bestMatch.path, hint: bestMatch.hint } : null; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 83227a375d5..c81e08ea3c3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -3,6 +3,7 @@ import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js"; +import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; import { applyDerivedTags } from "./schema.tags.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -500,52 +501,11 @@ function resolveUiHintMatch( uiHints: ConfigUiHints, path: string, ): { path: string; hint: ConfigUiHint } | null { - const targetParts = splitLookupPath(path); - let best: { path: string; hint: ConfigUiHint; wildcardCount: number } | null = null; - - for (const [hintPath, hint] of Object.entries(uiHints)) { - const hintParts = splitLookupPath(hintPath); - if (hintParts.length !== targetParts.length) { - continue; - } - - let wildcardCount = 0; - let matches = true; - for (let index = 0; index < hintParts.length; index += 1) { - const hintPart = hintParts[index]; - const targetPart = targetParts[index]; - if (hintPart === targetPart) { - continue; - } - if (hintPart === "*") { - wildcardCount += 1; - continue; - } - matches = false; - break; - } - if (!matches) { - continue; - } - if (!best || wildcardCount < best.wildcardCount) { - best = { path: hintPath, hint, wildcardCount }; - } - } - - return best ? { path: best.path, hint: best.hint } : null; -} - -function schemaHasChildren(schema: JsonSchemaObject): boolean { - if (schema.properties && Object.keys(schema.properties).length > 0) { - return true; - } - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - return true; - } - if (Array.isArray(schema.items)) { - return schema.items.some((entry) => typeof entry === "object" && entry !== null); - } - return Boolean(schema.items && typeof schema.items === "object"); + return findWildcardHintMatch({ + uiHints, + path, + splitPath: splitLookupPath, + }); } function resolveItemsSchema(schema: JsonSchemaObject, index?: number): JsonSchemaObject | null {