From 94a51d88ccabf8719af1152247e47d3bcf3da56a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 22:19:39 -0700 Subject: [PATCH] Docs: add config drift statefile generator --- .github/workflows/config-docs-drift.yml | 29 ++ docs/.generated/README.md | 7 + package.json | 2 + scripts/generate-config-doc-baseline.ts | 35 ++ src/config/doc-baseline.test.ts | 98 ++++++ src/config/doc-baseline.ts | 411 ++++++++++++++++++++++++ src/config/talk-defaults.test.ts | 13 +- 7 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/config-docs-drift.yml create mode 100644 docs/.generated/README.md create mode 100644 scripts/generate-config-doc-baseline.ts create mode 100644 src/config/doc-baseline.test.ts create mode 100644 src/config/doc-baseline.ts diff --git a/.github/workflows/config-docs-drift.yml b/.github/workflows/config-docs-drift.yml new file mode 100644 index 00000000000..212ddd4fa3c --- /dev/null +++ b/.github/workflows/config-docs-drift.yml @@ -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 diff --git a/docs/.generated/README.md b/docs/.generated/README.md new file mode 100644 index 00000000000..2729c6bc5b0 --- /dev/null +++ b/docs/.generated/README.md @@ -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`. diff --git a/package.json b/package.json index c63e72f66fa..e9292d4cf79 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-config-doc-baseline.ts b/scripts/generate-config-doc-baseline.ts new file mode 100644 index 00000000000..dd7b45f8e91 --- /dev/null +++ b/scripts/generate-config-doc-baseline.ts @@ -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)}`); diff --git a/src/config/doc-baseline.test.ts b/src/config/doc-baseline.test.ts new file mode 100644 index 00000000000..4e0e15bd1a3 --- /dev/null +++ b/src/config/doc-baseline.test.ts @@ -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); + }); +}); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts new file mode 100644 index 00000000000..5f7be0b8b0c --- /dev/null +++ b/src/config/doc-baseline.ts @@ -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; + +type JsonSchemaObject = JsonSchemaNode & { + type?: string | string[]; + properties?: Record; + 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) + .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 { + 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 { + 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; + 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 { + 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(); + 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 { + 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 { + 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 { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return null; + } +} + +async function writeIfChanged(filePath: string, next: string): Promise { + 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 { + 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 { + return Object.fromEntries( + Object.entries(FIELD_HELP) + .map(([configPath, help]) => [normalizeBaselinePath(configPath), help] as const) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); +} diff --git a/src/config/talk-defaults.test.ts b/src/config/talk-defaults.test.ts index 1be94ef2db4..6ff88aec265 100644 --- a/src/config/talk-defaults.test.ts +++ b/src/config/talk-defaults.test.ts @@ -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", () => {