fix(config): split config doc baseline coverage

This commit is contained in:
Vincent Koc
2026-03-20 00:03:01 -07:00
parent e56dde815e
commit 3a72d2d6de
4 changed files with 235 additions and 351 deletions

View File

@@ -0,0 +1,138 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildConfigDocBaseline,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline integration", () => {
const tempRoots: string[] = [];
let sharedBaselinePromise: Promise<Awaited<ReturnType<typeof buildConfigDocBaseline>>> | null =
null;
let sharedRenderedPromise: Promise<
Awaited<ReturnType<typeof renderConfigDocBaselineStatefile>>
> | null = null;
function getSharedBaseline() {
sharedBaselinePromise ??= buildConfigDocBaseline();
return sharedBaselinePromise;
}
function getSharedRendered() {
sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline());
return sharedRenderedPromise;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map(async (tempRoot) => {
await fs.rm(tempRoot, { recursive: true, force: true });
}),
);
});
it("is deterministic across repeated runs", async () => {
const first = await renderConfigDocBaselineStatefile();
const second = await renderConfigDocBaselineStatefile();
expect(second.json).toBe(first.json);
expect(second.jsonl).toBe(first.jsonl);
});
it("includes core, channel, and plugin config metadata", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("gateway.auth.token")).toMatchObject({
kind: "core",
sensitive: true,
});
expect(byPath.get("channels.telegram.botToken")).toMatchObject({
kind: "channel",
sensitive: true,
});
expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({
kind: "plugin",
sensitive: true,
});
});
it("preserves help text and tags from merged schema hints", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
const tokenEntry = byPath.get("gateway.auth.token");
expect(tokenEntry?.help).toContain("gateway access");
expect(tokenEntry?.tags).toContain("auth");
expect(tokenEntry?.tags).toContain("security");
});
it("matches array help hints that still use [] notation", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({
help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"),
sensitive: false,
});
});
it("walks union branches for nested config keys", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("bindings.*")).toMatchObject({
hasChildren: true,
});
expect(byPath.get("bindings.*.type")).toBeDefined();
expect(byPath.get("bindings.*.match.channel")).toBeDefined();
expect(byPath.get("bindings.*.match.peer.id")).toBeDefined();
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-"));
tempRoots.push(tempRoot);
const rendered = getSharedRendered();
const initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
rendered,
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(current.changed).toBe(false);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.json"),
'{"generatedBy":"broken","entries":[]}\n',
"utf8",
);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.jsonl"),
'{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n',
"utf8",
);
const stale = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

View File

@@ -1,107 +1,17 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildConfigDocBaseline,
collectConfigDocBaselineEntries,
dedupeConfigDocBaselineEntries,
normalizeConfigDocBaselineHelpPath,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline", () => {
const tempRoots: string[] = [];
let sharedBaselinePromise: Promise<Awaited<ReturnType<typeof buildConfigDocBaseline>>> | null =
null;
let sharedRenderedPromise: Promise<
Awaited<ReturnType<typeof renderConfigDocBaselineStatefile>>
> | null = null;
function getSharedBaseline() {
sharedBaselinePromise ??= buildConfigDocBaseline();
return sharedBaselinePromise;
}
function getSharedRendered() {
sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline());
return sharedRenderedPromise;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map(async (tempRoot) => {
await fs.rm(tempRoot, { recursive: true, force: true });
}),
);
});
it("is deterministic across repeated runs", async () => {
const first = await renderConfigDocBaselineStatefile();
const second = await renderConfigDocBaselineStatefile();
expect(second.json).toBe(first.json);
expect(second.jsonl).toBe(first.jsonl);
});
it("normalizes array and record paths to wildcard form", async () => {
const baseline = await getSharedBaseline();
const paths = new Set(baseline.entries.map((entry) => entry.path));
expect(paths.has("session.sendPolicy.rules.*.match.keyPrefix")).toBe(true);
expect(paths.has("env.*")).toBe(true);
expect(normalizeConfigDocBaselineHelpPath("agents.list[].skills")).toBe("agents.list.*.skills");
});
it("includes core, channel, and plugin config metadata", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("gateway.auth.token")).toMatchObject({
kind: "core",
sensitive: true,
});
expect(byPath.get("channels.telegram.botToken")).toMatchObject({
kind: "channel",
sensitive: true,
});
expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({
kind: "plugin",
sensitive: true,
});
});
it("preserves help text and tags from merged schema hints", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
const tokenEntry = byPath.get("gateway.auth.token");
expect(tokenEntry?.help).toContain("gateway access");
expect(tokenEntry?.tags).toContain("auth");
expect(tokenEntry?.tags).toContain("security");
});
it("matches array help hints that still use [] notation", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({
help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"),
sensitive: false,
});
});
it("walks union branches for nested config keys", async () => {
const baseline = await getSharedBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("bindings.*")).toMatchObject({
hasChildren: true,
});
expect(byPath.get("bindings.*.type")).toBeDefined();
expect(byPath.get("bindings.*.match.channel")).toBeDefined();
expect(byPath.get("bindings.*.match.peer.id")).toBeDefined();
expect(normalizeConfigDocBaselineHelpPath("session.sendPolicy.rules[0].match.keyPrefix")).toBe(
"session.sendPolicy.rules.*.match.keyPrefix",
);
expect(normalizeConfigDocBaselineHelpPath(".env.*.")).toBe("env.*");
});
it("merges tuple item metadata instead of dropping earlier entries", () => {
@@ -132,48 +42,4 @@ describe("config doc baseline", () => {
expect(tupleEntry?.enumValues).toEqual(expect.arrayContaining([42, "alpha"]));
expect(tupleEntry?.enumValues).toHaveLength(2);
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-"));
tempRoots.push(tempRoot);
const rendered = getSharedRendered();
const initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
rendered,
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(current.changed).toBe(false);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.json"),
'{"generatedBy":"broken","entries":[]}\n',
"utf8",
);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.jsonl"),
'{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n',
"utf8",
);
const stale = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
rendered,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

View File

@@ -1,13 +1,10 @@
import { spawnSync } from "node:child_process";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ChannelPlugin } from "../channels/plugins/index.js";
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 type { ConfigSchemaResponse } from "./schema.js";
import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
@@ -28,12 +25,6 @@ type JsonSchemaObject = JsonSchemaNode & {
oneOf?: JsonSchemaObject[];
};
type PackageChannelMetadata = {
id: string;
label: string;
blurb?: string;
};
type ChannelSurfaceMetadata = {
id: string;
label: string;
@@ -277,191 +268,11 @@ function resolveFirstExistingPath(candidates: string[]): string | null {
return null;
}
function loadPackageChannelMetadata(rootDir: string): PackageChannelMetadata | null {
try {
const packageJson = JSON.parse(
fsSync.readFileSync(path.join(rootDir, "package.json"), "utf8"),
) as {
openclaw?: {
channel?: {
id?: unknown;
label?: unknown;
blurb?: unknown;
};
};
};
const channel = packageJson.openclaw?.channel;
if (!channel) {
return null;
}
const id = typeof channel.id === "string" ? channel.id.trim() : "";
const label = typeof channel.label === "string" ? channel.label.trim() : "";
const blurb = typeof channel.blurb === "string" ? channel.blurb.trim() : "";
if (!id || !label) {
return null;
}
return {
id,
label,
...(blurb ? { blurb } : {}),
};
} catch {
return null;
}
}
function isChannelPlugin(value: unknown): value is ChannelPlugin {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as { id?: unknown; meta?: unknown; capabilities?: unknown };
return typeof candidate.id === "string" && typeof candidate.meta === "object";
}
function resolveSetupChannelPlugin(value: unknown): ChannelPlugin | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as { plugin?: unknown };
return isChannelPlugin(candidate.plugin) ? candidate.plugin : null;
}
async function importChannelPluginModule(rootDir: string): Promise<ChannelPlugin> {
logConfigDocBaselineDebug(`resolve channel module ${rootDir}`);
const modulePath = resolveFirstExistingPath([
path.join(rootDir, "setup-entry.ts"),
path.join(rootDir, "setup-entry.js"),
path.join(rootDir, "setup-entry.mts"),
path.join(rootDir, "setup-entry.mjs"),
path.join(rootDir, "src", "channel.ts"),
path.join(rootDir, "src", "channel.js"),
path.join(rootDir, "src", "plugin.ts"),
path.join(rootDir, "src", "plugin.js"),
path.join(rootDir, "src", "index.ts"),
path.join(rootDir, "src", "index.js"),
path.join(rootDir, "src", "channel.mts"),
path.join(rootDir, "src", "channel.mjs"),
path.join(rootDir, "src", "plugin.mts"),
path.join(rootDir, "src", "plugin.mjs"),
]);
if (!modulePath) {
throw new Error(`channel source not found under ${rootDir}`);
}
logConfigDocBaselineDebug(`import channel module ${modulePath}`);
const imported = (await import(modulePath)) as Record<string, unknown>;
logConfigDocBaselineDebug(`imported channel module ${modulePath}`);
for (const value of Object.values(imported)) {
if (isChannelPlugin(value)) {
logConfigDocBaselineDebug(`resolved channel export ${modulePath}`);
return value;
}
const setupPlugin = resolveSetupChannelPlugin(value);
if (setupPlugin) {
logConfigDocBaselineDebug(`resolved setup channel export ${modulePath}`);
return setupPlugin;
}
if (typeof value === "function" && value.length === 0) {
const resolved = value();
if (isChannelPlugin(resolved)) {
logConfigDocBaselineDebug(`resolved channel factory ${modulePath}`);
return resolved;
}
}
}
throw new Error(`channel plugin export not found in ${modulePath}`);
}
async function importChannelSurfaceMetadata(
rootDir: string,
repoRoot: string,
env: NodeJS.ProcessEnv,
): Promise<ChannelSurfaceMetadata | null> {
logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`);
const packageMetadata = loadPackageChannelMetadata(rootDir);
if (!packageMetadata) {
logConfigDocBaselineDebug(`missing package channel metadata ${rootDir}`);
return null;
}
const modulePath = resolveFirstExistingPath([
path.join(rootDir, "src", "config-schema.ts"),
path.join(rootDir, "src", "config-schema.js"),
path.join(rootDir, "src", "config-schema.mts"),
path.join(rootDir, "src", "config-schema.mjs"),
]);
if (!modulePath) {
logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`);
return null;
}
logConfigDocBaselineDebug(`import channel config schema ${modulePath}`);
try {
logConfigDocBaselineDebug(`spawn channel config schema subprocess ${modulePath}`);
const result = spawnSync(
process.execPath,
[
"--import",
"tsx",
path.join(repoRoot, "scripts", "load-channel-config-surface.ts"),
modulePath,
],
{
cwd: repoRoot,
encoding: "utf8",
env,
timeout: 15_000,
maxBuffer: 10 * 1024 * 1024,
},
);
if (result.status !== 0 || result.error) {
throw result.error ?? new Error(result.stderr || `child exited with status ${result.status}`);
}
logConfigDocBaselineDebug(`completed channel config schema subprocess ${modulePath}`);
const configSchema = JSON.parse(result.stdout) as {
schema: Record<string, unknown>;
uiHints?: ConfigSchemaResponse["uiHints"];
};
return {
id: packageMetadata.id,
label: packageMetadata.label,
description: packageMetadata.blurb,
configSchema: configSchema.schema,
configUiHints: configSchema.uiHints,
};
} catch (error) {
logConfigDocBaselineDebug(
`channel config schema subprocess failed for ${modulePath}: ${String(error)}`,
);
return null;
}
}
async function loadChannelSurfaceMetadata(
rootDir: string,
repoRoot: string,
env: NodeJS.ProcessEnv,
): Promise<ChannelSurfaceMetadata> {
logConfigDocBaselineDebug(`load channel surface ${rootDir}`);
const configSurface = await importChannelSurfaceMetadata(rootDir, repoRoot, env);
if (configSurface) {
logConfigDocBaselineDebug(`resolved channel config surface ${rootDir}`);
return configSurface;
}
logConfigDocBaselineDebug(`fallback to channel plugin import ${rootDir}`);
const plugin = await importChannelPluginModule(rootDir);
return {
id: plugin.id,
label: plugin.meta.label,
description: plugin.meta.blurb,
configSchema: plugin.configSchema?.schema,
configUiHints: plugin.configSchema?.uiHints,
};
}
async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse> {
const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([
import("../plugins/manifest-registry.js"),
import("./schema.js"),
]);
const repoRoot = resolveRepoRoot();
const env = {
...process.env,
@@ -479,22 +290,49 @@ async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse>
const bundledChannelPlugins = manifestRegistry.plugins.filter(
(plugin) => plugin.origin === "bundled" && plugin.channels.length > 0,
);
const loadChannelsSequentiallyForDebug = process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1";
const channelPlugins = loadChannelsSequentiallyForDebug
? await bundledChannelPlugins.reduce<Promise<ChannelSurfaceMetadata[]>>(
async (promise, plugin) => {
const loaded = await promise;
loaded.push(await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env));
return loaded;
},
Promise.resolve([]),
)
: await Promise.all(
bundledChannelPlugins.map(
async (plugin) => await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env),
),
);
logConfigDocBaselineDebug(`imported ${channelPlugins.length} bundled channel plugins`);
const channelPlugins =
process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1"
? await bundledChannelPlugins.reduce<Promise<ChannelSurfaceMetadata[]>>(
async (promise, plugin) => {
const loaded = await promise;
loaded.push(
(await loadChannelSurfaceMetadata(
plugin.rootDir,
plugin.id,
plugin.name ?? plugin.id,
repoRoot,
)) ?? {
id: plugin.id,
label: plugin.name ?? plugin.id,
description: plugin.description,
configSchema: plugin.configSchema,
configUiHints: plugin.configUiHints,
},
);
return loaded;
},
Promise.resolve([]),
)
: await Promise.all(
bundledChannelPlugins.map(
async (plugin) =>
(await loadChannelSurfaceMetadata(
plugin.rootDir,
plugin.id,
plugin.name ?? plugin.id,
repoRoot,
)) ?? {
id: plugin.id,
label: plugin.name ?? plugin.id,
description: plugin.description,
configSchema: plugin.configSchema,
configUiHints: plugin.configUiHints,
},
),
);
logConfigDocBaselineDebug(
`loaded ${channelPlugins.length} bundled channel entries from channel surfaces`,
);
return buildConfigSchema({
cache: false,
@@ -517,6 +355,48 @@ async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse>
});
}
async function loadChannelSurfaceMetadata(
rootDir: string,
id: string,
label: string,
repoRoot: string,
): Promise<ChannelSurfaceMetadata | null> {
logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`);
const modulePath = resolveFirstExistingPath([
path.join(rootDir, "src", "config-schema.ts"),
path.join(rootDir, "src", "config-schema.js"),
path.join(rootDir, "src", "config-schema.mts"),
path.join(rootDir, "src", "config-schema.mjs"),
]);
if (!modulePath) {
logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`);
return null;
}
logConfigDocBaselineDebug(`import channel config schema ${modulePath}`);
try {
const { loadChannelConfigSurfaceModule } =
await import("../../scripts/load-channel-config-surface.ts");
const configSurface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot });
if (!configSurface) {
logConfigDocBaselineDebug(`channel config schema export missing ${modulePath}`);
return null;
}
logConfigDocBaselineDebug(`completed channel config schema import ${modulePath}`);
return {
id,
label,
configSchema: configSurface.schema,
configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined,
};
} catch (error) {
logConfigDocBaselineDebug(
`channel config schema import failed for ${modulePath}: ${String(error)}`,
);
return null;
}
}
export function collectConfigDocBaselineEntries(
schema: JsonSchemaObject,
uiHints: ConfigSchemaResponse["uiHints"],

View File

@@ -14,7 +14,7 @@
"reason": "Mutates process.cwd() and core loader seams."
},
{
"file": "src/config/doc-baseline.test.ts",
"file": "src/config/doc-baseline.integration.test.ts",
"reason": "Rebuilds bundled config baselines through many channel schema subprocesses; keep out of the shared lane."
},
{
@@ -28,7 +28,7 @@
"reason": "Clean in isolation, but can hang after sharing the broad lane."
},
{
"file": "src/config/doc-baseline.test.ts",
"file": "src/config/doc-baseline.integration.test.ts",
"reason": "Builds the full bundled config schema graph and is safer outside the shared unit-fast heap."
},
{