refactor(config): share schema lookup helpers

This commit is contained in:
Peter Steinberger
2026-03-17 06:16:03 +00:00
parent 43838b1b14
commit 2ed5ad36ae
4 changed files with 113 additions and 103 deletions

View File

@@ -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 {

View File

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

View File

@@ -0,0 +1,73 @@
type JsonSchemaObject = {
properties?: Record<string, JsonSchemaObject>;
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<T>(params: {
uiHints: Record<string, T>;
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;
}

View File

@@ -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 {