mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
b080dd1e76
* refactor: consolidate coercion contracts Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics. Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit. * fix: preserve standalone script coercions Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd.
752 lines
23 KiB
TypeScript
752 lines
23 KiB
TypeScript
// Builds documentation baselines from config schema metadata.
|
|
import { createHash } from "node:crypto";
|
|
import fsSync from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
|
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
|
import { replaceFileAtomicSync } from "../infra/replace-file.js";
|
|
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
|
import type { ConfigSchemaResponse } from "./schema.js";
|
|
import { schemaHasChildren } from "./schema.shared.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;
|
|
anyOf?: JsonSchemaObject[];
|
|
allOf?: JsonSchemaObject[];
|
|
oneOf?: JsonSchemaObject[];
|
|
};
|
|
|
|
type ConfigDocBaselineKind = "core" | "channel" | "plugin";
|
|
|
|
type ConfigDocBaselineCounts = Record<ConfigDocBaselineKind, number>;
|
|
|
|
type ConfigDocBaselineCountViolation = {
|
|
kind: ConfigDocBaselineKind;
|
|
current: number;
|
|
budget: number;
|
|
message: string;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
type ConfigDocBaseline = {
|
|
generatedBy: "scripts/generate-config-doc-baseline.ts";
|
|
coreEntries: ConfigDocBaselineEntry[];
|
|
channelEntries: ConfigDocBaselineEntry[];
|
|
pluginEntries: ConfigDocBaselineEntry[];
|
|
};
|
|
|
|
type ConfigDocBaselineKindBaseline = {
|
|
generatedBy: "scripts/generate-config-doc-baseline.ts";
|
|
kind: ConfigDocBaselineKind;
|
|
entries: ConfigDocBaselineEntry[];
|
|
};
|
|
|
|
type ConfigDocBaselineArtifacts = {
|
|
combined: string;
|
|
core: string;
|
|
channel: string;
|
|
plugin: string;
|
|
};
|
|
|
|
type ConfigDocBaselineArtifactsRender = {
|
|
baseline: ConfigDocBaseline;
|
|
json: ConfigDocBaselineArtifacts;
|
|
};
|
|
|
|
type ConfigDocBaselineArtifactPaths = {
|
|
combined: string;
|
|
core: string;
|
|
channel: string;
|
|
plugin: string;
|
|
};
|
|
|
|
type ConfigDocBaselineArtifactsWriteResult = {
|
|
changed: boolean;
|
|
hashChanged: boolean;
|
|
wrote: boolean;
|
|
jsonPaths: ConfigDocBaselineArtifactPaths;
|
|
hashPath: string;
|
|
countsPath: string;
|
|
countViolations: ConfigDocBaselineCountViolation[];
|
|
countBudgetError?: string;
|
|
};
|
|
|
|
const GENERATED_BY = "scripts/generate-config-doc-baseline.ts" as const;
|
|
const DEFAULT_COMBINED_OUTPUT = "docs/.generated/config-baseline.json";
|
|
const DEFAULT_CORE_OUTPUT = "docs/.generated/config-baseline.core.json";
|
|
const DEFAULT_CHANNEL_OUTPUT = "docs/.generated/config-baseline.channel.json";
|
|
const DEFAULT_PLUGIN_OUTPUT = "docs/.generated/config-baseline.plugin.json";
|
|
const DEFAULT_HASH_OUTPUT = "docs/.generated/config-baseline.sha256";
|
|
const DEFAULT_COUNTS_OUTPUT = "docs/.generated/config-baseline.counts.json";
|
|
// A successful schema snapshot is process-stable; failures clear below so tooling can retry.
|
|
let cachedConfigDocBaselinePromise: Promise<ConfigDocBaseline> | null = null;
|
|
const uiHintIndexCache = new WeakMap<
|
|
ConfigSchemaResponse["uiHints"],
|
|
Map<
|
|
number,
|
|
Array<{ path: string; parts: string[]; hint: ConfigSchemaResponse["uiHints"][string] }>
|
|
>
|
|
>();
|
|
const schemaHasChildrenCache = new WeakMap<JsonSchemaObject, boolean>();
|
|
|
|
function compareBaselineStrings(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
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)), "../..");
|
|
}
|
|
|
|
const loadDocBaselineRuntime = createLazyRuntimeModule(() => import("./doc-baseline.runtime.js"));
|
|
|
|
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]) => compareBaselineStrings(left, 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 {
|
|
return asNullableRecord(value) as JsonSchemaObject | null;
|
|
}
|
|
|
|
function splitHintLookupPath(pathResult: string): string[] {
|
|
const normalized = normalizeBaselinePath(pathResult);
|
|
return normalized ? normalized.split(".").filter(Boolean) : [];
|
|
}
|
|
|
|
function resolveUiHintMatch(
|
|
uiHints: ConfigSchemaResponse["uiHints"],
|
|
pathLocal: string,
|
|
): ConfigSchemaResponse["uiHints"][string] | undefined {
|
|
const targetParts = splitHintLookupPath(pathLocal);
|
|
if (targetParts.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
let index = uiHintIndexCache.get(uiHints);
|
|
if (!index) {
|
|
index = new Map();
|
|
for (const [hintPath, hint] of Object.entries(uiHints)) {
|
|
const parts = splitHintLookupPath(hintPath);
|
|
const bucket = index.get(parts.length);
|
|
const entry = { path: hintPath, parts, hint };
|
|
if (bucket) {
|
|
bucket.push(entry);
|
|
} else {
|
|
index.set(parts.length, [entry]);
|
|
}
|
|
}
|
|
uiHintIndexCache.set(uiHints, index);
|
|
}
|
|
|
|
const candidates = index.get(targetParts.length);
|
|
if (!candidates) {
|
|
return undefined;
|
|
}
|
|
|
|
let bestMatch:
|
|
| {
|
|
hint: ConfigSchemaResponse["uiHints"][string];
|
|
wildcardCount: number;
|
|
}
|
|
| undefined;
|
|
|
|
for (const candidate of candidates) {
|
|
let wildcardCount = 0;
|
|
let matches = true;
|
|
for (let indexLocal = 0; indexLocal < candidate.parts.length; indexLocal += 1) {
|
|
const hintPart = candidate.parts[indexLocal];
|
|
const targetPart = targetParts[indexLocal];
|
|
if (hintPart === targetPart) {
|
|
continue;
|
|
}
|
|
if (hintPart === "*") {
|
|
wildcardCount += 1;
|
|
continue;
|
|
}
|
|
matches = false;
|
|
break;
|
|
}
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
if (!bestMatch || wildcardCount < bestMatch.wildcardCount) {
|
|
bestMatch = { hint: candidate.hint, wildcardCount };
|
|
if (wildcardCount === 0) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestMatch?.hint;
|
|
}
|
|
|
|
function resolveSchemaHasChildren(schema: JsonSchemaObject): boolean {
|
|
const cached = schemaHasChildrenCache.get(schema);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
const next = schemaHasChildren(schema);
|
|
schemaHasChildrenCache.set(schema, next);
|
|
return next;
|
|
}
|
|
|
|
function normalizeTypeValue(value: string | string[] | undefined): string | string[] | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const normalized = sortUniqueStrings(value);
|
|
return normalized.length === 1 ? normalized[0] : normalized;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function mergeTypeValues(
|
|
left: string | string[] | undefined,
|
|
right: string | string[] | undefined,
|
|
): string | string[] | undefined {
|
|
const merged = new Set<string>();
|
|
for (const value of [left, right]) {
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
merged.add(entry);
|
|
}
|
|
continue;
|
|
}
|
|
merged.add(value);
|
|
}
|
|
return normalizeTypeValue([...merged]);
|
|
}
|
|
|
|
function areJsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
function mergeJsonValueArrays(
|
|
left: JsonValue[] | undefined,
|
|
right: JsonValue[] | undefined,
|
|
): JsonValue[] | undefined {
|
|
if (!left?.length) {
|
|
return right ? [...right] : undefined;
|
|
}
|
|
if (!right?.length) {
|
|
return [...left];
|
|
}
|
|
|
|
const merged = new Map<string, JsonValue>();
|
|
for (const value of [...left, ...right]) {
|
|
merged.set(JSON.stringify(value), value);
|
|
}
|
|
return [...merged.entries()]
|
|
.toSorted(([leftKey], [rightKey]) => compareBaselineStrings(leftKey, rightKey))
|
|
.map(([, value]) => value);
|
|
}
|
|
|
|
function mergeConfigDocBaselineEntry(
|
|
current: ConfigDocBaselineEntry,
|
|
next: ConfigDocBaselineEntry,
|
|
): ConfigDocBaselineEntry {
|
|
const label = current.label === next.label ? current.label : (current.label ?? next.label);
|
|
const help = current.help === next.help ? current.help : (current.help ?? next.help);
|
|
const defaultValue = areJsonValuesEqual(current.defaultValue, next.defaultValue)
|
|
? (current.defaultValue ?? next.defaultValue)
|
|
: undefined;
|
|
|
|
return {
|
|
path: current.path,
|
|
kind: current.kind,
|
|
type: mergeTypeValues(current.type, next.type),
|
|
required: current.required && next.required,
|
|
enumValues: mergeJsonValueArrays(current.enumValues, next.enumValues),
|
|
defaultValue,
|
|
deprecated: current.deprecated || next.deprecated,
|
|
sensitive: current.sensitive || next.sensitive,
|
|
tags: sortUniqueStrings([...current.tags, ...next.tags]),
|
|
label,
|
|
help,
|
|
hasChildren: current.hasChildren || next.hasChildren,
|
|
};
|
|
}
|
|
|
|
function resolveEntryKind(configPath: string): ConfigDocBaselineKind {
|
|
if (configPath.startsWith("channels.")) {
|
|
return "channel";
|
|
}
|
|
if (configPath.startsWith("plugins.entries.")) {
|
|
return "plugin";
|
|
}
|
|
return "core";
|
|
}
|
|
|
|
async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse> {
|
|
const repoRoot = resolveRepoRoot();
|
|
const runtime = await loadDocBaselineRuntime();
|
|
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 = runtime.loadPluginManifestRegistry({
|
|
env,
|
|
config: {},
|
|
bundledChannelConfigCollector: runtime.collectBundledChannelConfigs,
|
|
});
|
|
const bundledRegistry = {
|
|
...manifestRegistry,
|
|
plugins: manifestRegistry.plugins.filter((plugin) => plugin.origin === "bundled"),
|
|
};
|
|
const channelPlugins = runtime.collectChannelSchemaMetadata(bundledRegistry);
|
|
|
|
return runtime.buildConfigSchema({
|
|
plugins: runtime.collectPluginSchemaMetadata(bundledRegistry),
|
|
channels: channelPlugins,
|
|
});
|
|
}
|
|
|
|
function collectConfigDocBaselineEntries(
|
|
schema: JsonSchemaObject,
|
|
uiHints: ConfigSchemaResponse["uiHints"],
|
|
pathPrefix = "",
|
|
required = false,
|
|
entries: ConfigDocBaselineEntry[] = [],
|
|
visited = new WeakMap<JsonSchemaObject, Set<string>>(),
|
|
): ConfigDocBaselineEntry[] {
|
|
const normalizedPath = normalizeBaselinePath(pathPrefix);
|
|
const visitKey = `${normalizedPath}|${required ? "1" : "0"}`;
|
|
const visitedPaths = visited.get(schema);
|
|
if (visitedPaths?.has(visitKey)) {
|
|
return entries;
|
|
}
|
|
if (visitedPaths) {
|
|
visitedPaths.add(visitKey);
|
|
} else {
|
|
visited.set(schema, new Set([visitKey]));
|
|
}
|
|
|
|
if (normalizedPath) {
|
|
const hint = resolveUiHintMatch(uiHints, normalizedPath);
|
|
entries.push({
|
|
path: normalizedPath,
|
|
kind: resolveEntryKind(normalizedPath),
|
|
type: normalizeTypeValue(schema.type),
|
|
required,
|
|
enumValues: normalizeEnumValues(schema.enum),
|
|
defaultValue: normalizeJsonValue(schema.default),
|
|
deprecated: schema.deprecated === true,
|
|
sensitive: hint?.sensitive === true,
|
|
tags: [...(hint?.tags ?? [])].toSorted(compareBaselineStrings),
|
|
label: hint?.label,
|
|
help: hint?.help,
|
|
hasChildren: resolveSchemaHasChildren(schema),
|
|
});
|
|
}
|
|
|
|
const requiredKeys = new Set(schema.required ?? []);
|
|
for (const key of Object.keys(schema.properties ?? {}).toSorted(compareBaselineStrings)) {
|
|
const child = asSchemaObject(schema.properties?.[key]);
|
|
if (!child) {
|
|
continue;
|
|
}
|
|
const childPath = normalizedPath ? `${normalizedPath}.${key}` : key;
|
|
collectConfigDocBaselineEntries(
|
|
child,
|
|
uiHints,
|
|
childPath,
|
|
requiredKeys.has(key),
|
|
entries,
|
|
visited,
|
|
);
|
|
}
|
|
|
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
const wildcard = asSchemaObject(schema.additionalProperties);
|
|
if (wildcard) {
|
|
const wildcardPath = normalizedPath ? `${normalizedPath}.*` : "*";
|
|
collectConfigDocBaselineEntries(wildcard, uiHints, wildcardPath, false, entries, visited);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(schema.items)) {
|
|
for (const item of schema.items) {
|
|
const child = asSchemaObject(item);
|
|
if (!child) {
|
|
continue;
|
|
}
|
|
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
|
|
collectConfigDocBaselineEntries(child, uiHints, itemPath, false, entries, visited);
|
|
}
|
|
} else if (schema.items && typeof schema.items === "object") {
|
|
const itemSchema = asSchemaObject(schema.items);
|
|
if (itemSchema) {
|
|
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
|
|
collectConfigDocBaselineEntries(itemSchema, uiHints, itemPath, false, entries, visited);
|
|
}
|
|
}
|
|
|
|
for (const branchSchema of [schema.oneOf, schema.anyOf, schema.allOf]) {
|
|
for (const branch of branchSchema ?? []) {
|
|
const child = asSchemaObject(branch);
|
|
if (!child) {
|
|
continue;
|
|
}
|
|
collectConfigDocBaselineEntries(child, uiHints, normalizedPath, required, entries, visited);
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function dedupeConfigDocBaselineEntries(
|
|
entries: ConfigDocBaselineEntry[],
|
|
): ConfigDocBaselineEntry[] {
|
|
const byPath = new Map<string, ConfigDocBaselineEntry>();
|
|
for (const entry of entries) {
|
|
const current = byPath.get(entry.path);
|
|
byPath.set(entry.path, current ? mergeConfigDocBaselineEntry(current, entry) : entry);
|
|
}
|
|
return [...byPath.values()].toSorted((left, right) =>
|
|
compareBaselineStrings(left.path, right.path),
|
|
);
|
|
}
|
|
|
|
function splitConfigDocBaselineEntries(entries: ConfigDocBaselineEntry[]): {
|
|
coreEntries: ConfigDocBaselineEntry[];
|
|
channelEntries: ConfigDocBaselineEntry[];
|
|
pluginEntries: ConfigDocBaselineEntry[];
|
|
} {
|
|
const coreEntries: ConfigDocBaselineEntry[] = [];
|
|
const channelEntries: ConfigDocBaselineEntry[] = [];
|
|
const pluginEntries: ConfigDocBaselineEntry[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry.kind === "channel") {
|
|
channelEntries.push(entry);
|
|
continue;
|
|
}
|
|
if (entry.kind === "plugin") {
|
|
pluginEntries.push(entry);
|
|
continue;
|
|
}
|
|
coreEntries.push(entry);
|
|
}
|
|
|
|
return { coreEntries, channelEntries, pluginEntries };
|
|
}
|
|
|
|
async function buildConfigDocBaseline(): Promise<ConfigDocBaseline> {
|
|
if (cachedConfigDocBaselinePromise) {
|
|
return await cachedConfigDocBaselinePromise;
|
|
}
|
|
cachedConfigDocBaselinePromise = (async () => {
|
|
const response = await loadBundledConfigSchemaResponse();
|
|
const schemaRoot = asSchemaObject(response.schema);
|
|
if (!schemaRoot) {
|
|
throw new Error("config schema root is not an object");
|
|
}
|
|
const entries = dedupeConfigDocBaselineEntries(
|
|
collectConfigDocBaselineEntries(schemaRoot, response.uiHints),
|
|
);
|
|
const { coreEntries, channelEntries, pluginEntries } = splitConfigDocBaselineEntries(entries);
|
|
return {
|
|
generatedBy: GENERATED_BY,
|
|
coreEntries,
|
|
channelEntries,
|
|
pluginEntries,
|
|
};
|
|
})();
|
|
try {
|
|
return await cachedConfigDocBaselinePromise;
|
|
} catch (error) {
|
|
cachedConfigDocBaselinePromise = null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function renderKindBaseline(
|
|
kind: ConfigDocBaselineKind,
|
|
entries: ConfigDocBaselineEntry[],
|
|
): string {
|
|
const baseline: ConfigDocBaselineKindBaseline = {
|
|
generatedBy: GENERATED_BY,
|
|
kind,
|
|
entries,
|
|
};
|
|
return `${JSON.stringify(baseline, null, 2)}\n`;
|
|
}
|
|
|
|
export async function renderConfigDocBaselineArtifacts(
|
|
baseline?: ConfigDocBaseline | Promise<ConfigDocBaseline>,
|
|
): Promise<ConfigDocBaselineArtifactsRender> {
|
|
const resolvedBaseline = baseline ? await baseline : await buildConfigDocBaseline();
|
|
const json: ConfigDocBaselineArtifacts = {
|
|
combined: `${JSON.stringify(resolvedBaseline, null, 2)}\n`,
|
|
core: renderKindBaseline("core", resolvedBaseline.coreEntries),
|
|
channel: renderKindBaseline("channel", resolvedBaseline.channelEntries),
|
|
plugin: renderKindBaseline("plugin", resolvedBaseline.pluginEntries),
|
|
};
|
|
return {
|
|
json,
|
|
baseline: resolvedBaseline,
|
|
};
|
|
}
|
|
|
|
function readFileIfExists(filePath: string): string | null {
|
|
try {
|
|
return fsSync.readFileSync(filePath, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeFileAtomic(filePath: string, content: string): void {
|
|
replaceFileAtomicSync({
|
|
filePath,
|
|
content,
|
|
dirMode: 0o755,
|
|
mode: 0o644,
|
|
tempPrefix: path.basename(filePath),
|
|
});
|
|
}
|
|
|
|
function sha256(content: string): string {
|
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
}
|
|
|
|
/** Build the sha256 hash file content for all config baseline artifacts. */
|
|
function computeConfigBaselineHashFileContent(json: ConfigDocBaselineArtifacts): string {
|
|
const lines = [
|
|
`${sha256(json.combined)} config-baseline.json`,
|
|
`${sha256(json.core)} config-baseline.core.json`,
|
|
`${sha256(json.channel)} config-baseline.channel.json`,
|
|
`${sha256(json.plugin)} config-baseline.plugin.json`,
|
|
];
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function computeConfigBaselineCounts(baseline: ConfigDocBaseline): ConfigDocBaselineCounts {
|
|
return {
|
|
core: baseline.coreEntries.length,
|
|
channel: baseline.channelEntries.length,
|
|
plugin: baseline.pluginEntries.length,
|
|
};
|
|
}
|
|
|
|
function renderConfigBaselineCounts(counts: ConfigDocBaselineCounts): string {
|
|
return `${JSON.stringify(counts, null, 2)}\n`;
|
|
}
|
|
|
|
function parseConfigBaselineCounts(content: string | null): ConfigDocBaselineCounts {
|
|
if (content === null) {
|
|
throw new Error("count budget file is missing");
|
|
}
|
|
const parsed = JSON.parse(content) as unknown;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("count budget must be a JSON object");
|
|
}
|
|
const record = parsed as Record<string, unknown>;
|
|
for (const kind of ["core", "channel", "plugin"] as const) {
|
|
if (!Number.isInteger(record[kind]) || (record[kind] as number) < 0) {
|
|
throw new Error(`${kind} budget must be a non-negative integer`);
|
|
}
|
|
}
|
|
return {
|
|
core: record.core as number,
|
|
channel: record.channel as number,
|
|
plugin: record.plugin as number,
|
|
};
|
|
}
|
|
|
|
function collectConfigBaselineCountViolations(
|
|
current: ConfigDocBaselineCounts,
|
|
budget: ConfigDocBaselineCounts,
|
|
): ConfigDocBaselineCountViolation[] {
|
|
const violations: ConfigDocBaselineCountViolation[] = [];
|
|
for (const kind of ["core", "channel", "plugin"] as const) {
|
|
if (current[kind] === budget[kind]) {
|
|
continue;
|
|
}
|
|
const message =
|
|
current[kind] > budget[kind]
|
|
? `${kind}: current ${current[kind]} > budget ${budget[kind]}; config surface grew; either remove config elsewhere or consciously raise the budget in docs/.generated/config-baseline.counts.json in this PR and justify it in the PR body. See the AGENTS.md config-surface bar.`
|
|
: `${kind}: current ${current[kind]} < budget ${budget[kind]}; budget is stale; run pnpm config:docs:gen to ratchet it down.`;
|
|
violations.push({
|
|
kind,
|
|
current: current[kind],
|
|
budget: budget[kind],
|
|
message,
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function resolveBaselineArtifactPaths(
|
|
repoRoot: string,
|
|
params?: {
|
|
combinedPath?: string;
|
|
corePath?: string;
|
|
channelPath?: string;
|
|
pluginPath?: string;
|
|
},
|
|
): ConfigDocBaselineArtifactPaths {
|
|
return {
|
|
combined: path.resolve(repoRoot, params?.combinedPath ?? DEFAULT_COMBINED_OUTPUT),
|
|
core: path.resolve(repoRoot, params?.corePath ?? DEFAULT_CORE_OUTPUT),
|
|
channel: path.resolve(repoRoot, params?.channelPath ?? DEFAULT_CHANNEL_OUTPUT),
|
|
plugin: path.resolve(repoRoot, params?.pluginPath ?? DEFAULT_PLUGIN_OUTPUT),
|
|
};
|
|
}
|
|
|
|
export async function writeConfigDocBaselineArtifacts(params?: {
|
|
repoRoot?: string;
|
|
check?: boolean;
|
|
combinedPath?: string;
|
|
corePath?: string;
|
|
channelPath?: string;
|
|
pluginPath?: string;
|
|
hashPath?: string;
|
|
countsPath?: string;
|
|
rendered?: ConfigDocBaselineArtifactsRender | Promise<ConfigDocBaselineArtifactsRender>;
|
|
}): Promise<ConfigDocBaselineArtifactsWriteResult> {
|
|
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
|
|
const jsonPaths = resolveBaselineArtifactPaths(repoRoot, params);
|
|
const hashPath = path.resolve(repoRoot, params?.hashPath ?? DEFAULT_HASH_OUTPUT);
|
|
const countsPath = path.resolve(repoRoot, params?.countsPath ?? DEFAULT_COUNTS_OUTPUT);
|
|
const rendered = params?.rendered
|
|
? await params.rendered
|
|
: await renderConfigDocBaselineArtifacts();
|
|
|
|
const nextHashContent = computeConfigBaselineHashFileContent(rendered.json);
|
|
const counts = computeConfigBaselineCounts(rendered.baseline);
|
|
const nextCountsContent = renderConfigBaselineCounts(counts);
|
|
const currentHashContent = readFileIfExists(hashPath);
|
|
const hashChanged = currentHashContent !== nextHashContent;
|
|
let countBudgetError: string | undefined;
|
|
let countViolations: ConfigDocBaselineCountViolation[] = [];
|
|
try {
|
|
countViolations = collectConfigBaselineCountViolations(
|
|
counts,
|
|
parseConfigBaselineCounts(readFileIfExists(countsPath)),
|
|
);
|
|
} catch (error) {
|
|
countBudgetError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
const changed = hashChanged || countBudgetError !== undefined || countViolations.length > 0;
|
|
|
|
if (params?.check) {
|
|
return {
|
|
changed,
|
|
hashChanged,
|
|
wrote: false,
|
|
jsonPaths,
|
|
hashPath,
|
|
countsPath,
|
|
countViolations,
|
|
...(countBudgetError ? { countBudgetError } : {}),
|
|
};
|
|
}
|
|
|
|
// Write tracked drift-detection artifacts.
|
|
writeFileAtomic(hashPath, nextHashContent);
|
|
writeFileAtomic(countsPath, nextCountsContent);
|
|
|
|
// Write full JSON artifacts locally (gitignored, useful for inspection)
|
|
for (const key of Object.keys(jsonPaths) as Array<keyof ConfigDocBaselineArtifacts>) {
|
|
writeFileAtomic(jsonPaths[key], rendered.json[key]);
|
|
}
|
|
|
|
return {
|
|
changed,
|
|
hashChanged,
|
|
wrote: true,
|
|
jsonPaths,
|
|
hashPath,
|
|
countsPath,
|
|
countViolations: [],
|
|
};
|
|
}
|