mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
a0ad38e71a
* feat(docs-ci): validate config examples against schema * docs: teach canonical agent roster configuration * docs: nest inline environment variables under vars * docs: update canonical media model keys * docs: remove retired config keys from examples * fix(docs-ci): keep audit internals private * feat(docs-ci): validate plugin-owned config examples * fix(docs-ci): validate indented config fences * fix(ci): route manifest changes through docs audit * chore(docs): refresh generated config baselines
279 lines
8.2 KiB
TypeScript
279 lines
8.2 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import JSON5 from "json5";
|
|
import {
|
|
loadPluginMetadataSnapshot,
|
|
type PluginMetadataSnapshot,
|
|
} from "../plugins/plugin-metadata-snapshot.js";
|
|
import { validateConfigObjectRaw, validateConfigObjectRawWithPlugins } from "./validation.js";
|
|
import { OpenClawSchemaShape } from "./zod-schema.root-shape.js";
|
|
|
|
type DocsConfigFinding = {
|
|
filePath: string;
|
|
fenceStartLine: number;
|
|
issuePath: string;
|
|
message: string;
|
|
};
|
|
|
|
type DocsConfigStats = {
|
|
filesScanned: number;
|
|
fencesSeen: number;
|
|
candidatesValidated: number;
|
|
fencesSkipped: number;
|
|
skippedUnsupportedLanguage: number;
|
|
skippedOptOut: number;
|
|
skippedParseFailure: number;
|
|
skippedNonObject: number;
|
|
skippedFragment: number;
|
|
};
|
|
|
|
type DocsConfigAudit = {
|
|
findings: DocsConfigFinding[];
|
|
stats: DocsConfigStats;
|
|
};
|
|
|
|
type MarkdownFence = {
|
|
info: string;
|
|
body: string;
|
|
startLine: number;
|
|
};
|
|
|
|
type DocsConfigValidationContext = {
|
|
env: NodeJS.ProcessEnv;
|
|
pluginMetadataSnapshot: Pick<PluginMetadataSnapshot, "manifestRegistry">;
|
|
};
|
|
|
|
const ROOT_CONFIG_KEYS = new Set(Object.keys(OpenClawSchemaShape));
|
|
|
|
function emptyStats(filesScanned = 0): DocsConfigStats {
|
|
return {
|
|
filesScanned,
|
|
fencesSeen: 0,
|
|
candidatesValidated: 0,
|
|
fencesSkipped: 0,
|
|
skippedUnsupportedLanguage: 0,
|
|
skippedOptOut: 0,
|
|
skippedParseFailure: 0,
|
|
skippedNonObject: 0,
|
|
skippedFragment: 0,
|
|
};
|
|
}
|
|
|
|
function extractMarkdownFences(markdown: string): MarkdownFence[] {
|
|
const lines = markdown.split(/\r?\n/u);
|
|
const fences: MarkdownFence[] = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
// MDX component-nested fences (Accordion/Tabs) are commonly indented 4+ spaces;
|
|
// CommonMark's indented-code rule does not apply inside these docs components.
|
|
const opening = lines[index]?.match(/^([ \t]*)(`{3,}|~{3,})(.*)$/u);
|
|
if (!opening) {
|
|
continue;
|
|
}
|
|
const indent = opening[1];
|
|
const marker = opening[2];
|
|
if (indent === undefined || !marker) {
|
|
continue;
|
|
}
|
|
const body: string[] = [];
|
|
const startLine = index + 1;
|
|
const closing = new RegExp(`^[ \\t]*${marker.charAt(0)}{${marker.length},}[ \\t]*$`, "u");
|
|
index += 1;
|
|
while (index < lines.length && !closing.test(lines[index] ?? "")) {
|
|
body.push(lines[index] ?? "");
|
|
index += 1;
|
|
}
|
|
fences.push({
|
|
info: opening[3]?.trim() ?? "",
|
|
body: body.join("\n"),
|
|
startLine,
|
|
});
|
|
}
|
|
return fences;
|
|
}
|
|
|
|
function isConfigFence(info: string): boolean {
|
|
return /^(?:json5|json|jsonc)(?:\s|$)/iu.test(info);
|
|
}
|
|
|
|
function isUnrecognizedKeyMessage(message: string): boolean {
|
|
return /(?:Unrecognized keys?|must not have additional properties):\s*"/iu.test(message);
|
|
}
|
|
|
|
function isWholeConfig(parsed: Record<string, unknown>): boolean {
|
|
const topLevelKeys = Object.keys(parsed);
|
|
const recognizedKeys = topLevelKeys.filter((key) => ROOT_CONFIG_KEYS.has(key));
|
|
// Accepted tradeoff: equal config/non-config mixes and documents containing only
|
|
// retired root keys look like fragments and are skipped.
|
|
return recognizedKeys.length > topLevelKeys.length / 2;
|
|
}
|
|
|
|
function stripIncludeKeys(value: unknown): unknown {
|
|
if (Array.isArray(value)) {
|
|
return value.map(stripIncludeKeys);
|
|
}
|
|
if (!isRecord(value)) {
|
|
return value;
|
|
}
|
|
// src/config/includes.ts resolves these directives before schema validation.
|
|
// Docs validation drops them recursively to mirror that pipeline boundary.
|
|
return Object.fromEntries(
|
|
Object.entries(value).flatMap(([key, child]) =>
|
|
key === "$include" ? [] : [[key, stripIncludeKeys(child)]],
|
|
),
|
|
);
|
|
}
|
|
|
|
function createDocsConfigValidationContext(): DocsConfigValidationContext {
|
|
const env = {
|
|
...process.env,
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"),
|
|
};
|
|
return {
|
|
env,
|
|
pluginMetadataSnapshot: loadPluginMetadataSnapshot({
|
|
config: {},
|
|
env,
|
|
preferPersisted: false,
|
|
allowCurrent: false,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function auditConfigMarkdown(
|
|
params: { markdown: string; filePath: string },
|
|
validationContext: DocsConfigValidationContext,
|
|
): DocsConfigAudit {
|
|
const findings: DocsConfigFinding[] = [];
|
|
const stats = emptyStats(1);
|
|
|
|
for (const fence of extractMarkdownFences(params.markdown)) {
|
|
stats.fencesSeen += 1;
|
|
if (!isConfigFence(fence.info)) {
|
|
stats.fencesSkipped += 1;
|
|
stats.skippedUnsupportedLanguage += 1;
|
|
continue;
|
|
}
|
|
if (/\bvalidate=false\b/iu.test(fence.info)) {
|
|
stats.fencesSkipped += 1;
|
|
stats.skippedOptOut += 1;
|
|
continue;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON5.parse(fence.body);
|
|
} catch {
|
|
stats.fencesSkipped += 1;
|
|
stats.skippedParseFailure += 1;
|
|
continue;
|
|
}
|
|
if (!isRecord(parsed)) {
|
|
stats.fencesSkipped += 1;
|
|
stats.skippedNonObject += 1;
|
|
continue;
|
|
}
|
|
if (!isWholeConfig(parsed)) {
|
|
stats.fencesSkipped += 1;
|
|
stats.skippedFragment += 1;
|
|
continue;
|
|
}
|
|
|
|
const validationInput = stripIncludeKeys(parsed);
|
|
// Keep these validators separate so ValidateConfigWithPluginsParams stays outside the
|
|
// Plugin SDK declaration closure; changing it shifts every SDK module baseline hash.
|
|
const results = [
|
|
validateConfigObjectRaw(validationInput, { validateBundledChannels: true }),
|
|
validateConfigObjectRawWithPlugins(validationInput, validationContext),
|
|
];
|
|
stats.candidatesValidated += 1;
|
|
// This gate catches retired keys only. Placeholder type errors and incomplete
|
|
// illustrative values remain outside its contract.
|
|
const seenIssues = new Set<string>();
|
|
for (const result of results) {
|
|
if (result.ok) {
|
|
continue;
|
|
}
|
|
for (const issue of result.issues) {
|
|
if (!isUnrecognizedKeyMessage(issue.message)) {
|
|
continue;
|
|
}
|
|
const issueKey = JSON.stringify([issue.path, issue.message]);
|
|
if (seenIssues.has(issueKey)) {
|
|
continue;
|
|
}
|
|
seenIssues.add(issueKey);
|
|
findings.push({
|
|
filePath: params.filePath,
|
|
fenceStartLine: fence.startLine,
|
|
issuePath: issue.path,
|
|
message: issue.message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
findings: findings.toSorted(
|
|
(left, right) =>
|
|
left.filePath.localeCompare(right.filePath) ||
|
|
left.fenceStartLine - right.fenceStartLine ||
|
|
left.issuePath.localeCompare(right.issuePath),
|
|
),
|
|
stats,
|
|
};
|
|
}
|
|
|
|
// Keep this in sync with the generated-doc locale test in scripts/docs-link-audit.mts.
|
|
function isLocalizedDocPath(filePath: string): boolean {
|
|
return /^\/?[a-z]{2}(?:-[A-Za-z]{2,8})+\//u.test(filePath);
|
|
}
|
|
|
|
function listDocsFiles(docsRoot: string): string[] {
|
|
const files: string[] = [];
|
|
const walk = (directory: string): void => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
if (entry.name.startsWith(".")) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(entryPath);
|
|
} else if (entry.isFile() && /\.mdx?$/iu.test(entry.name)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
};
|
|
walk(docsRoot);
|
|
return files.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
/** Audits English docs config examples against the current strict schema. */
|
|
export function auditDocsConfigExamples(params: { repoRoot: string }): DocsConfigAudit {
|
|
const docsRoot = path.join(params.repoRoot, "docs");
|
|
const findings: DocsConfigFinding[] = [];
|
|
const stats = emptyStats();
|
|
const validationContext = createDocsConfigValidationContext();
|
|
|
|
for (const filePath of listDocsFiles(docsRoot)) {
|
|
const docsRelativePath = path.relative(docsRoot, filePath).split(path.sep).join("/");
|
|
if (isLocalizedDocPath(docsRelativePath)) {
|
|
continue;
|
|
}
|
|
const repoRelativePath = path.posix.join("docs", docsRelativePath);
|
|
const audit = auditConfigMarkdown(
|
|
{
|
|
markdown: fs.readFileSync(filePath, "utf8"),
|
|
filePath: repoRelativePath,
|
|
},
|
|
validationContext,
|
|
);
|
|
findings.push(...audit.findings);
|
|
for (const [key, value] of Object.entries(audit.stats)) {
|
|
stats[key as keyof DocsConfigStats] += value;
|
|
}
|
|
}
|
|
|
|
return { findings, stats };
|
|
}
|