mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 08:31:49 -06:00
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
// API baseline helpers render public SDK exports for contract drift checks.
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import ts from "typescript";
|
|
import {
|
|
pluginSdkDocMetadata,
|
|
type PluginSdkDocCategory,
|
|
type PluginSdkDocEntrypoint,
|
|
} from "../../scripts/lib/plugin-sdk-doc-metadata.ts";
|
|
import {
|
|
createDeclarationClosureRenderer,
|
|
formatPluginSdkDiagnostics,
|
|
} from "./api-baseline-declaration-closure.js";
|
|
import { printPluginSdkExportDeclaration } from "./api-baseline-declaration-print.js";
|
|
import { normalizePluginSdkApiSourcePath as relativePath } from "./api-baseline-normalization.js";
|
|
import { publicPluginSdkEntrypoints } from "./entrypoints.ts";
|
|
|
|
export {
|
|
normalizePluginSdkApiDeclarationText,
|
|
normalizePluginSdkApiSourcePath,
|
|
} from "./api-baseline-normalization.js";
|
|
|
|
/** Declaration kind recorded for each public SDK export in the API baseline. */
|
|
export type PluginSdkApiExportKind =
|
|
| "class"
|
|
| "const"
|
|
| "enum"
|
|
| "function"
|
|
| "interface"
|
|
| "namespace"
|
|
| "type"
|
|
| "unknown"
|
|
| "variable";
|
|
|
|
/** Repo source location for a public SDK declaration or module. */
|
|
export type PluginSdkApiSourceLink = {
|
|
/** Repo-relative source file path. */
|
|
path: string;
|
|
};
|
|
|
|
/** One named export captured from a public SDK entrypoint. */
|
|
export type PluginSdkApiExport = {
|
|
/** Hash of repo-owned declarations reachable from this export. */
|
|
closureHash: string | null;
|
|
/** Normalized TypeScript declaration text, or null when TypeScript cannot print it. */
|
|
declaration: string | null;
|
|
/** Exported symbol name as plugin authors import it. */
|
|
exportName: string;
|
|
/** Coarse declaration kind used by docs and drift reports. */
|
|
kind: PluginSdkApiExportKind;
|
|
/** Source location for the exported declaration when available. */
|
|
source: PluginSdkApiSourceLink | null;
|
|
};
|
|
|
|
/** API baseline record for one public SDK module/subpath. */
|
|
export type PluginSdkApiModule = {
|
|
/** Documentation category used to group SDK entrypoints when documented. */
|
|
category: PluginSdkDocCategory | null;
|
|
/** Canonical public SDK entrypoint. */
|
|
entrypoint: string;
|
|
/** Public exports discovered from the TypeScript program. */
|
|
exports: PluginSdkApiExport[];
|
|
/** Package specifier shown to plugin authors. */
|
|
importSpecifier: string;
|
|
/** Repo source for the SDK entrypoint file. */
|
|
source: PluginSdkApiSourceLink;
|
|
};
|
|
|
|
/** Full generated SDK API baseline payload. */
|
|
export type PluginSdkApiBaseline = {
|
|
/** Generator identifier used to reject hand-authored baseline files. */
|
|
generatedBy: "scripts/generate-plugin-sdk-api-baseline.ts";
|
|
/** Public SDK modules included in the baseline. */
|
|
modules: PluginSdkApiModule[];
|
|
};
|
|
|
|
/** One committed Plugin SDK module contract file. */
|
|
export type PluginSdkApiBaselineContractFile = {
|
|
/** Complete one-line JSON record, including its trailing newline. */
|
|
content: string;
|
|
/** Filename relative to the committed contract directory. */
|
|
fileName: string;
|
|
};
|
|
|
|
/** Rendered baseline variants written to local and committed outputs. */
|
|
export type PluginSdkApiBaselineRender = {
|
|
/** Structured baseline data before serialization. */
|
|
baseline: PluginSdkApiBaseline;
|
|
/** One deterministic committed contract file per public SDK entrypoint. */
|
|
contractFiles: PluginSdkApiBaselineContractFile[];
|
|
/** Pretty JSON artifact for humans and docs tooling. */
|
|
json: string;
|
|
};
|
|
|
|
/** File-level drift in the committed Plugin SDK API contract directory. */
|
|
export type PluginSdkApiBaselineContractDiff = {
|
|
/** Expected records whose committed content differs. */
|
|
modified: string[];
|
|
/** Expected records absent from the committed directory. */
|
|
missing: string[];
|
|
/** Unexpected JSON records left in the committed directory. */
|
|
stale: string[];
|
|
};
|
|
|
|
/** Result returned when writing SDK API baseline artifacts. */
|
|
export type PluginSdkApiBaselineWriteResult = {
|
|
/** True when the generated contract directory differs from disk. */
|
|
changed: boolean;
|
|
/** File-level drift when a check finds contract differences. */
|
|
contractDiff: PluginSdkApiBaselineContractDiff | null;
|
|
/** Committed per-entrypoint contract directory. */
|
|
contractDirectory: string;
|
|
/** True when generated artifacts were actually written. */
|
|
wrote: boolean;
|
|
/** JSON baseline artifact path. */
|
|
jsonPath: string;
|
|
/** Number of stale JSON records removed in write mode. */
|
|
removedStaleCount: number;
|
|
};
|
|
|
|
const GENERATED_BY = "scripts/generate-plugin-sdk-api-baseline.ts" as const;
|
|
const DEFAULT_JSON_OUTPUT = "docs/.generated/plugin-sdk-api-baseline.json";
|
|
const DEFAULT_CONTRACT_DIRECTORY = "docs/.generated/plugin-sdk-api-baseline";
|
|
const SAFE_ENTRYPOINT_FILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
type DeclarationClosureRenderer = ReturnType<typeof createDeclarationClosureRenderer>;
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function resolveRepoRoot(): string {
|
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
}
|
|
|
|
function createCompilerContext(repoRoot: string, entrypoints: readonly string[]) {
|
|
const configPath = ts.findConfigFile(
|
|
repoRoot,
|
|
(filePath) => ts.sys.fileExists(filePath),
|
|
"tsconfig.json",
|
|
);
|
|
assert(configPath, "Could not find tsconfig.json");
|
|
const configFile = ts.readConfigFile(configPath, (filePath) => ts.sys.readFile(filePath));
|
|
if (configFile.error) {
|
|
throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
|
|
}
|
|
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot);
|
|
if (parsedConfig.errors.length > 0) {
|
|
throw new Error(formatPluginSdkDiagnostics(parsedConfig.errors, repoRoot));
|
|
}
|
|
const fileNames = entrypoints
|
|
.map((entrypoint) => path.join(repoRoot, "src", "plugin-sdk", `${entrypoint}.ts`))
|
|
.toSorted((left, right) =>
|
|
compareText(
|
|
relativePath(repoRoot, path.resolve(left)),
|
|
relativePath(repoRoot, path.resolve(right)),
|
|
),
|
|
);
|
|
const program = ts.createProgram(fileNames, {
|
|
...parsedConfig.options,
|
|
declaration: true,
|
|
declarationMap: false,
|
|
emitDeclarationOnly: true,
|
|
noEmit: false,
|
|
// Declaration diagnostics are checked explicitly; unrelated untyped external JS stays valid.
|
|
noEmitOnError: false,
|
|
removeComments: true,
|
|
sourceMap: false,
|
|
});
|
|
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: true });
|
|
return {
|
|
checker: program.getTypeChecker(),
|
|
declarationClosure: createDeclarationClosureRenderer({
|
|
printer,
|
|
program,
|
|
repoRoot,
|
|
}),
|
|
printer,
|
|
program,
|
|
};
|
|
}
|
|
|
|
/** List canonical public SDK entrypoints included in the API baseline. */
|
|
export function listPluginSdkApiBaselineEntrypoints(): string[] {
|
|
return [...publicPluginSdkEntrypoints];
|
|
}
|
|
|
|
function inferExportKind(
|
|
symbol: ts.Symbol,
|
|
declaration: ts.Declaration | undefined,
|
|
): PluginSdkApiExportKind {
|
|
if (declaration) {
|
|
switch (declaration.kind) {
|
|
case ts.SyntaxKind.ClassDeclaration:
|
|
return "class";
|
|
case ts.SyntaxKind.EnumDeclaration:
|
|
return "enum";
|
|
case ts.SyntaxKind.FunctionDeclaration:
|
|
return "function";
|
|
case ts.SyntaxKind.InterfaceDeclaration:
|
|
return "interface";
|
|
case ts.SyntaxKind.ModuleDeclaration:
|
|
return "namespace";
|
|
case ts.SyntaxKind.TypeAliasDeclaration:
|
|
return "type";
|
|
case ts.SyntaxKind.VariableDeclaration: {
|
|
const variableStatement = declaration.parent?.parent;
|
|
if (
|
|
variableStatement &&
|
|
ts.isVariableStatement(variableStatement) &&
|
|
(ts.getCombinedNodeFlags(variableStatement.declarationList) & ts.NodeFlags.Const) !== 0
|
|
) {
|
|
return "const";
|
|
}
|
|
return "variable";
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const [flag, kind] of [
|
|
[ts.SymbolFlags.Function, "function"],
|
|
[ts.SymbolFlags.Class, "class"],
|
|
[ts.SymbolFlags.Interface, "interface"],
|
|
[ts.SymbolFlags.TypeAlias, "type"],
|
|
[ts.SymbolFlags.ConstEnum | ts.SymbolFlags.RegularEnum, "enum"],
|
|
[ts.SymbolFlags.Variable, "variable"],
|
|
[ts.SymbolFlags.NamespaceModule | ts.SymbolFlags.ValueModule, "namespace"],
|
|
] as const) {
|
|
if (symbol.flags & flag) {
|
|
return kind;
|
|
}
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
function resolveSymbolAndDeclaration(
|
|
checker: ts.TypeChecker,
|
|
repoRoot: string,
|
|
symbol: ts.Symbol,
|
|
): {
|
|
declaration: ts.Declaration | undefined;
|
|
resolvedSymbol: ts.Symbol;
|
|
} {
|
|
const resolvedSymbol =
|
|
symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
|
|
const declarations = (
|
|
resolvedSymbol.getDeclarations() ??
|
|
symbol.getDeclarations() ??
|
|
[]
|
|
).toSorted((left, right) => compareDeclarations(repoRoot, left, right));
|
|
const declaration = declarations.find((candidate) => candidate.kind !== ts.SyntaxKind.SourceFile);
|
|
return { declaration, resolvedSymbol };
|
|
}
|
|
|
|
function compareText(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
function compareDeclarations(
|
|
repoRoot: string,
|
|
left: ts.Declaration,
|
|
right: ts.Declaration,
|
|
): number {
|
|
return (
|
|
compareText(
|
|
relativePath(repoRoot, left.getSourceFile().fileName),
|
|
relativePath(repoRoot, right.getSourceFile().fileName),
|
|
) ||
|
|
left.getStart() - right.getStart() ||
|
|
left.kind - right.kind
|
|
);
|
|
}
|
|
|
|
function buildExportSurface(params: {
|
|
checker: ts.TypeChecker;
|
|
declarationClosure: DeclarationClosureRenderer;
|
|
printer: ts.Printer;
|
|
repoRoot: string;
|
|
symbol: ts.Symbol;
|
|
}): PluginSdkApiExport {
|
|
const { checker, declarationClosure, printer, repoRoot, symbol } = params;
|
|
const { declaration, resolvedSymbol } = resolveSymbolAndDeclaration(checker, repoRoot, symbol);
|
|
const exportName = symbol.getName();
|
|
const declarationName = declaration ? ts.getNameOfDeclaration(declaration) : undefined;
|
|
const closureName =
|
|
declarationName && ts.isIdentifier(declarationName) ? declarationName.text : exportName;
|
|
const declarationText = declaration
|
|
? printPluginSdkExportDeclaration(repoRoot, checker, printer, declaration, exportName)
|
|
: null;
|
|
const declarationSource = declaration?.getSourceFile();
|
|
return {
|
|
closureHash:
|
|
declarationSource && declarationText
|
|
? (declarationClosure(declarationSource, closureName)?.hash ?? null)
|
|
: null,
|
|
declaration: declarationText,
|
|
exportName,
|
|
kind: inferExportKind(resolvedSymbol, declaration),
|
|
source: declarationSource ? { path: relativePath(repoRoot, declarationSource.fileName) } : null,
|
|
};
|
|
}
|
|
|
|
const EXPORT_KIND_SORT_RANK: Record<PluginSdkApiExportKind, number> = {
|
|
function: 0,
|
|
const: 1,
|
|
variable: 2,
|
|
type: 3,
|
|
interface: 4,
|
|
class: 5,
|
|
enum: 6,
|
|
namespace: 7,
|
|
unknown: 8,
|
|
};
|
|
|
|
function sortExports(left: PluginSdkApiExport, right: PluginSdkApiExport): number {
|
|
return (
|
|
EXPORT_KIND_SORT_RANK[left.kind] - EXPORT_KIND_SORT_RANK[right.kind] ||
|
|
compareText(left.exportName, right.exportName)
|
|
);
|
|
}
|
|
|
|
function buildModuleSurface(params: {
|
|
checker: ts.TypeChecker;
|
|
declarationClosure: DeclarationClosureRenderer;
|
|
printer: ts.Printer;
|
|
program: ts.Program;
|
|
repoRoot: string;
|
|
entrypoint: string;
|
|
}): PluginSdkApiModule {
|
|
const { checker, declarationClosure, printer, program, repoRoot, entrypoint } = params;
|
|
const metadata = Object.hasOwn(pluginSdkDocMetadata, entrypoint)
|
|
? pluginSdkDocMetadata[entrypoint as PluginSdkDocEntrypoint]
|
|
: undefined;
|
|
const importSpecifier = `openclaw/plugin-sdk/${entrypoint}`;
|
|
const moduleSourcePath = path.join(repoRoot, "src", "plugin-sdk", `${entrypoint}.ts`);
|
|
const sourceFile = program.getSourceFile(moduleSourcePath);
|
|
assert(sourceFile, `Missing source file for ${importSpecifier}`);
|
|
|
|
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
|
|
assert(moduleSymbol, `Unable to resolve module symbol for ${importSpecifier}`);
|
|
|
|
const exports = checker
|
|
.getExportsOfModule(moduleSymbol)
|
|
.filter((symbol) => symbol.getName() !== "__esModule")
|
|
.map((symbol) =>
|
|
buildExportSurface({
|
|
checker,
|
|
declarationClosure,
|
|
printer,
|
|
repoRoot,
|
|
symbol,
|
|
}),
|
|
)
|
|
.toSorted(sortExports);
|
|
|
|
return {
|
|
category: metadata?.category ?? null,
|
|
entrypoint,
|
|
exports,
|
|
importSpecifier,
|
|
source: { path: relativePath(repoRoot, moduleSourcePath) },
|
|
};
|
|
}
|
|
|
|
function sha256(content: string): string {
|
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
}
|
|
|
|
function buildContractFiles(baseline: PluginSdkApiBaseline): PluginSdkApiBaselineContractFile[] {
|
|
return baseline.modules.map((moduleSurface) => {
|
|
assert(
|
|
SAFE_ENTRYPOINT_FILE_NAME.test(moduleSurface.entrypoint),
|
|
`Plugin SDK entrypoint is not filename-safe: ${moduleSurface.entrypoint}`,
|
|
);
|
|
const contractSurface = {
|
|
category: moduleSurface.category,
|
|
entrypoint: moduleSurface.entrypoint,
|
|
exports: moduleSurface.exports.map((exportSurface) => ({
|
|
closureHash: exportSurface.closureHash,
|
|
declaration: exportSurface.declaration,
|
|
exportName: exportSurface.exportName,
|
|
kind: exportSurface.kind,
|
|
})),
|
|
importSpecifier: moduleSurface.importSpecifier,
|
|
};
|
|
return {
|
|
content: `${JSON.stringify({
|
|
contentHash: sha256(JSON.stringify(contractSurface)),
|
|
entrypoint: moduleSurface.entrypoint,
|
|
importSpecifier: moduleSurface.importSpecifier,
|
|
})}\n`,
|
|
fileName: `${moduleSurface.entrypoint}.json`,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Render the current public SDK API baseline without writing generated artifacts. */
|
|
export async function renderPluginSdkApiBaseline(params?: {
|
|
repoRoot?: string;
|
|
entrypoints?: readonly string[];
|
|
}): Promise<PluginSdkApiBaselineRender> {
|
|
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
|
|
const entrypoints = params?.entrypoints ?? listPluginSdkApiBaselineEntrypoints();
|
|
validateMetadata();
|
|
const { checker, declarationClosure, printer, program } = createCompilerContext(
|
|
repoRoot,
|
|
entrypoints,
|
|
);
|
|
const modules = [...entrypoints].toSorted(compareText).map((entrypoint) =>
|
|
buildModuleSurface({
|
|
checker,
|
|
declarationClosure,
|
|
printer,
|
|
program,
|
|
repoRoot,
|
|
entrypoint,
|
|
}),
|
|
);
|
|
|
|
return renderPluginSdkApiBaselineModules(modules);
|
|
}
|
|
|
|
/** Serialize discovered SDK modules in canonical order without rebuilding declarations. */
|
|
export function renderPluginSdkApiBaselineModules(
|
|
modules: readonly PluginSdkApiModule[],
|
|
): PluginSdkApiBaselineRender {
|
|
const baseline: PluginSdkApiBaseline = {
|
|
generatedBy: GENERATED_BY,
|
|
modules: [...modules].toSorted((left, right) =>
|
|
compareText(left.importSpecifier, right.importSpecifier),
|
|
),
|
|
};
|
|
|
|
return {
|
|
baseline,
|
|
contractFiles: buildContractFiles(baseline),
|
|
json: `${JSON.stringify(baseline, null, 2)}\n`,
|
|
};
|
|
}
|
|
|
|
async function loadCurrentFile(filePath: string): Promise<string | null> {
|
|
try {
|
|
return await fs.readFile(filePath, "utf8");
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function validateMetadata(): void {
|
|
const canonicalEntrypoints = new Set<string>(publicPluginSdkEntrypoints);
|
|
const metadataEntrypoints = new Set<string>(Object.keys(pluginSdkDocMetadata));
|
|
|
|
for (const entrypoint of metadataEntrypoints) {
|
|
assert(
|
|
canonicalEntrypoints.has(entrypoint),
|
|
`Metadata entrypoint ${entrypoint} is not exported in the Plugin SDK.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function listContractFileNames(contractDirectory: string): Promise<string[]> {
|
|
try {
|
|
return (await fs.readdir(contractDirectory, { withFileTypes: true }))
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => entry.name)
|
|
.toSorted(compareText);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Compare or write an already-rendered SDK API contract. */
|
|
export async function writeRenderedPluginSdkApiBaselineArtifacts(params: {
|
|
check?: boolean;
|
|
contractDirectory: string;
|
|
jsonPath: string;
|
|
rendered: PluginSdkApiBaselineRender;
|
|
}): Promise<PluginSdkApiBaselineWriteResult> {
|
|
const currentFileNames = await listContractFileNames(params.contractDirectory);
|
|
const expectedFileNames = new Set(params.rendered.contractFiles.map((file) => file.fileName));
|
|
const contractDiff: PluginSdkApiBaselineContractDiff = {
|
|
modified: [],
|
|
missing: [],
|
|
stale: currentFileNames.filter((fileName) => !expectedFileNames.has(fileName)),
|
|
};
|
|
for (const contractFile of params.rendered.contractFiles) {
|
|
const current = await loadCurrentFile(
|
|
path.join(params.contractDirectory, contractFile.fileName),
|
|
);
|
|
if (current === null) {
|
|
contractDiff.missing.push(contractFile.fileName);
|
|
} else if (current !== contractFile.content) {
|
|
contractDiff.modified.push(contractFile.fileName);
|
|
}
|
|
}
|
|
const changed = Object.values(contractDiff).some((fileNames) => fileNames.length > 0);
|
|
|
|
if (params.check) {
|
|
return {
|
|
changed,
|
|
contractDiff: changed ? contractDiff : null,
|
|
contractDirectory: params.contractDirectory,
|
|
wrote: false,
|
|
jsonPath: params.jsonPath,
|
|
removedStaleCount: 0,
|
|
};
|
|
}
|
|
|
|
await fs.mkdir(params.contractDirectory, { recursive: true });
|
|
for (const contractFile of params.rendered.contractFiles) {
|
|
await fs.writeFile(
|
|
path.join(params.contractDirectory, contractFile.fileName),
|
|
contractFile.content,
|
|
"utf8",
|
|
);
|
|
}
|
|
for (const fileName of contractDiff.stale) {
|
|
await fs.unlink(path.join(params.contractDirectory, fileName));
|
|
}
|
|
await fs.mkdir(path.dirname(params.jsonPath), { recursive: true });
|
|
await fs.writeFile(params.jsonPath, params.rendered.json, "utf8");
|
|
|
|
return {
|
|
changed,
|
|
contractDiff: null,
|
|
contractDirectory: params.contractDirectory,
|
|
wrote: true,
|
|
jsonPath: params.jsonPath,
|
|
removedStaleCount: contractDiff.stale.length,
|
|
};
|
|
}
|
|
|
|
/** Render, then write or check SDK API contract artifacts used by CI and release checks. */
|
|
export async function writePluginSdkApiBaselineArtifacts(params?: {
|
|
repoRoot?: string;
|
|
check?: boolean;
|
|
contractDirectory?: string;
|
|
jsonPath?: string;
|
|
}): Promise<PluginSdkApiBaselineWriteResult> {
|
|
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
|
|
return writeRenderedPluginSdkApiBaselineArtifacts({
|
|
check: params?.check,
|
|
contractDirectory: path.resolve(
|
|
repoRoot,
|
|
params?.contractDirectory ?? DEFAULT_CONTRACT_DIRECTORY,
|
|
),
|
|
jsonPath: path.resolve(repoRoot, params?.jsonPath ?? DEFAULT_JSON_OUTPUT),
|
|
rendered: await renderPluginSdkApiBaseline({ repoRoot }),
|
|
});
|
|
}
|