Docs: add config drift statefile generator

This commit is contained in:
Vincent Koc
2026-03-13 22:19:39 -07:00
parent aaeb348bb7
commit 94a51d88cc
7 changed files with 593 additions and 2 deletions

29
.github/workflows/config-docs-drift.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Config Docs Drift
on:
workflow_dispatch:
concurrency:
group: config-docs-drift-${{ github.ref }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
config-docs-drift:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Check config docs drift statefile
run: pnpm config:docs:check

View File

@@ -0,0 +1,7 @@
# Generated Docs Artifacts
This statefile is generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
- Do not edit `config-baseline.jsonl` by hand.
- Regenerate it with `pnpm config:docs:gen`.
- Validate it in CI or locally with `pnpm config:docs:check`.

View File

@@ -232,6 +232,8 @@
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
"config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write",
"deadcode:ci": "pnpm deadcode:report:ci:knip",
"deadcode:knip": "pnpm dlx knip --config knip.config.ts --isolate-workspaces --production --no-progress --reporter compact --files --dependencies",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { writeConfigDocBaselineStatefile } from "../src/config/doc-baseline.js";
const args = new Set(process.argv.slice(2));
const checkOnly = args.has("--check");
if (checkOnly && args.has("--write")) {
console.error("Use either --check or --write, not both.");
process.exit(1);
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await writeConfigDocBaselineStatefile({
repoRoot,
check: checkOnly,
});
if (checkOnly) {
if (!result.changed) {
console.log(`OK ${path.relative(repoRoot, result.statefilePath)}`);
process.exit(0);
}
console.error(
[
"Config doc baseline statefile is out of date.",
`Expected current: ${path.relative(repoRoot, result.statefilePath)}`,
"Run: node --import tsx scripts/generate-config-doc-baseline.ts --write",
].join("\n"),
);
process.exit(1);
}
console.log(`Wrote ${path.relative(repoRoot, result.statefilePath)}`);

View File

@@ -0,0 +1,98 @@
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,
normalizeConfigDocBaselineHelpPath,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline", () => {
const tempRoots: string[] = [];
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.jsonl).toBe(first.jsonl);
});
it("normalizes array and record paths to wildcard form", async () => {
const baseline = await buildConfigDocBaseline();
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 buildConfigDocBaseline();
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 buildConfigDocBaseline();
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("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 initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
statefilePath: "docs/.generated/config-baseline.jsonl",
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
});
expect(current.changed).toBe(false);
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,
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

411
src/config/doc-baseline.ts Normal file
View File

@@ -0,0 +1,411 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } 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";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type JsonSchemaNode = Record<string, unknown>;
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
enum?: unknown[];
default?: unknown;
deprecated?: boolean;
};
export type ConfigDocBaselineKind = "core" | "channel" | "plugin";
export type ConfigDocBaselineEntry = {
path: string;
kind: ConfigDocBaselineKind;
type?: string | string[];
required: boolean;
enumValues?: JsonValue[];
defaultValue?: JsonValue;
deprecated: boolean;
sensitive: boolean;
tags: string[];
label?: string;
help?: string;
hasChildren: boolean;
};
export type ConfigDocBaseline = {
generatedBy: "scripts/generate-config-doc-baseline.ts";
entries: ConfigDocBaselineEntry[];
};
export type ConfigDocBaselineStatefileRender = {
jsonl: string;
baseline: ConfigDocBaseline;
};
export type ConfigDocBaselineStatefileWriteResult = {
changed: boolean;
wrote: boolean;
statefilePath: string;
};
const GENERATED_BY = "scripts/generate-config-doc-baseline.ts" as const;
const DEFAULT_STATEFILE_OUTPUT = "docs/.generated/config-baseline.jsonl";
function resolveRepoRoot(): string {
const fromPackage = resolveOpenClawPackageRootSync({
cwd: path.dirname(fileURLToPath(import.meta.url)),
moduleUrl: import.meta.url,
});
if (fromPackage) {
return fromPackage;
}
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
function normalizeBaselinePath(rawPath: string): string {
return rawPath
.trim()
.replace(/\[\]/g, ".*")
.replace(/\[(\*|\d+)\]/g, ".*")
.replace(/^\.+|\.+$/g, "")
.replace(/\.+/g, ".");
}
function normalizeJsonValue(value: unknown): JsonValue | undefined {
if (value === null) {
return null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (Array.isArray(value)) {
const normalized = value
.map((entry) => normalizeJsonValue(entry))
.filter((entry): entry is JsonValue => entry !== undefined);
return normalized;
}
if (!value || typeof value !== "object") {
return undefined;
}
const entries = Object.entries(value as Record<string, unknown>)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => {
const normalized = normalizeJsonValue(entry);
return normalized === undefined ? null : ([key, normalized] as const);
})
.filter((entry): entry is readonly [string, JsonValue] => entry !== null);
return Object.fromEntries(entries);
}
function normalizeEnumValues(values: unknown[] | undefined): JsonValue[] | undefined {
if (!values) {
return undefined;
}
const normalized = values
.map((entry) => normalizeJsonValue(entry))
.filter((entry): entry is JsonValue => entry !== undefined);
return normalized.length > 0 ? normalized : undefined;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return 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);
}
return Boolean(schema.items && typeof schema.items === "object");
}
function resolveEntryKind(configPath: string): ConfigDocBaselineKind {
if (configPath.startsWith("channels.")) {
return "channel";
}
if (configPath.startsWith("plugins.entries.")) {
return "plugin";
}
return "core";
}
async function resolveFirstExistingPath(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// Keep scanning for other source file variants.
}
}
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";
}
async function importChannelPluginModule(rootDir: string): Promise<ChannelPlugin> {
const modulePath = await resolveFirstExistingPath([
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}`);
}
const imported = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>;
for (const value of Object.values(imported)) {
if (isChannelPlugin(value)) {
return value;
}
if (typeof value === "function" && value.length === 0) {
const resolved = value();
if (isChannelPlugin(resolved)) {
return resolved;
}
}
}
throw new Error(`channel plugin export not found in ${modulePath}`);
}
async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse> {
const repoRoot = resolveRepoRoot();
const env = {
...process.env,
HOME: os.tmpdir(),
OPENCLAW_STATE_DIR: path.join(os.tmpdir(), "openclaw-config-doc-baseline-state"),
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"),
};
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
});
const channelPlugins = await Promise.all(
manifestRegistry.plugins
.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0)
.map(async (plugin) => ({
id: plugin.id,
channel: await importChannelPluginModule(plugin.rootDir),
})),
);
return buildConfigSchema({
plugins: manifestRegistry.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configSchema,
})),
channels: channelPlugins.map((entry) => ({
id: entry.channel.id,
label: entry.channel.meta.label,
description: entry.channel.meta.blurb,
configSchema: entry.channel.configSchema?.schema,
configUiHints: entry.channel.configSchema?.uiHints,
})),
});
}
function walkSchema(
schema: JsonSchemaObject,
uiHints: ConfigSchemaResponse["uiHints"],
pathPrefix = "",
required = false,
entries: ConfigDocBaselineEntry[] = [],
): ConfigDocBaselineEntry[] {
const normalizedPath = normalizeBaselinePath(pathPrefix);
if (normalizedPath) {
const hint = uiHints[normalizedPath];
entries.push({
path: normalizedPath,
kind: resolveEntryKind(normalizedPath),
type: Array.isArray(schema.type) ? [...schema.type] : schema.type,
required,
enumValues: normalizeEnumValues(schema.enum),
defaultValue: normalizeJsonValue(schema.default),
deprecated: schema.deprecated === true,
sensitive: hint?.sensitive === true,
tags: [...(hint?.tags ?? [])].toSorted((left, right) => left.localeCompare(right)),
label: hint?.label,
help: hint?.help,
hasChildren: schemaHasChildren(schema),
});
}
const requiredKeys = new Set(schema.required ?? []);
for (const key of Object.keys(schema.properties ?? {}).toSorted((left, right) =>
left.localeCompare(right),
)) {
const child = asSchemaObject(schema.properties?.[key]);
if (!child) {
continue;
}
const childPath = normalizedPath ? `${normalizedPath}.${key}` : key;
walkSchema(child, uiHints, childPath, requiredKeys.has(key), entries);
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
const wildcard = asSchemaObject(schema.additionalProperties);
if (wildcard) {
const wildcardPath = normalizedPath ? `${normalizedPath}.*` : "*";
walkSchema(wildcard, uiHints, wildcardPath, false, entries);
}
}
if (Array.isArray(schema.items)) {
for (const item of schema.items) {
const child = asSchemaObject(item);
if (!child) {
continue;
}
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
walkSchema(child, uiHints, itemPath, false, entries);
}
} else if (schema.items && typeof schema.items === "object") {
const itemSchema = asSchemaObject(schema.items);
if (itemSchema) {
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
walkSchema(itemSchema, uiHints, itemPath, false, entries);
}
}
return entries;
}
function dedupeEntries(entries: ConfigDocBaselineEntry[]): ConfigDocBaselineEntry[] {
const byPath = new Map<string, ConfigDocBaselineEntry>();
for (const entry of entries) {
byPath.set(entry.path, entry);
}
return [...byPath.values()].toSorted((left, right) => left.path.localeCompare(right.path));
}
export async function buildConfigDocBaseline(): Promise<ConfigDocBaseline> {
const response = await loadBundledConfigSchemaResponse();
const schemaRoot = asSchemaObject(response.schema);
if (!schemaRoot) {
throw new Error("config schema root is not an object");
}
const entries = dedupeEntries(walkSchema(schemaRoot, response.uiHints));
return {
generatedBy: GENERATED_BY,
entries,
};
}
export async function renderConfigDocBaselineStatefile(
baseline?: ConfigDocBaseline,
): Promise<ConfigDocBaselineStatefileRender> {
const resolvedBaseline = baseline ?? (await buildConfigDocBaseline());
const metadataLine = JSON.stringify({
generatedBy: GENERATED_BY,
recordType: "meta",
totalPaths: resolvedBaseline.entries.length,
});
const entryLines = resolvedBaseline.entries.map((entry) =>
JSON.stringify({
recordType: "path",
...entry,
}),
);
return {
jsonl: `${[metadataLine, ...entryLines].join("\n")}\n`,
baseline: resolvedBaseline,
};
}
async function readIfExists(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf8");
} catch {
return null;
}
}
async function writeIfChanged(filePath: string, next: string): Promise<boolean> {
const current = await readIfExists(filePath);
if (current === next) {
return false;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, next, "utf8");
return true;
}
export async function writeConfigDocBaselineStatefile(params?: {
repoRoot?: string;
check?: boolean;
statefilePath?: string;
}): Promise<ConfigDocBaselineStatefileWriteResult> {
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
const statefilePath = path.resolve(repoRoot, params?.statefilePath ?? DEFAULT_STATEFILE_OUTPUT);
const rendered = await renderConfigDocBaselineStatefile();
const currentStatefile = await readIfExists(statefilePath);
const changed = currentStatefile !== rendered.jsonl;
if (params?.check) {
return {
changed,
wrote: false,
statefilePath,
};
}
const wrote = await writeIfChanged(statefilePath, rendered.jsonl);
return {
changed,
wrote,
statefilePath,
};
}
export function normalizeConfigDocBaselineHelpPath(pathValue: string): string {
return normalizeBaselinePath(pathValue);
}
export function getNormalizedFieldHelp(): Record<string, string> {
return Object.fromEntries(
Object.entries(FIELD_HELP)
.map(([configPath, help]) => [normalizeBaselinePath(configPath), help] as const)
.toSorted(([left], [right]) => left.localeCompare(right)),
);
}

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { normalizeConfigDocBaselineHelpPath } from "./doc-baseline.js";
import { FIELD_HELP } from "./schema.help.js";
import {
describeTalkSilenceTimeoutDefaults,
@@ -17,10 +18,18 @@ function readRepoFile(relativePath: string): string {
describe("talk silence timeout defaults", () => {
it("keeps help text and docs aligned with the policy", () => {
const defaultsDescription = describeTalkSilenceTimeoutDefaults();
const baselineLines = readRepoFile("docs/.generated/config-baseline.jsonl")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { recordType: string; path?: string; help?: string });
const talkEntry = baselineLines.find(
(entry) =>
entry.recordType === "path" &&
entry.path === normalizeConfigDocBaselineHelpPath("talk.silenceTimeoutMs"),
);
expect(FIELD_HELP["talk.silenceTimeoutMs"]).toContain(defaultsDescription);
expect(readRepoFile("docs/gateway/configuration-reference.md")).toContain(defaultsDescription);
expect(readRepoFile("docs/nodes/talk.md")).toContain(defaultsDescription);
expect(talkEntry?.help).toContain(defaultsDescription);
});
it("matches the Apple and Android runtime constants", () => {