Files
openclaw/src/plugin-sdk/api-baseline.ts
T
Ayaan Zaidi d2afbd05ad refactor(plugin-sdk): replace API baselines with diffs (#123036)
* refactor(plugin-sdk): replace API baselines with diffs

* perf(plugin-sdk): bound API diff resources

* fix(plugin-sdk): isolate API diff dependencies

* fix(release): forward Plugin SDK acknowledgement

* fix(release): enforce SDK acknowledgement on publish

* chore: preserve generated-doc ignore policy

* fix(release): freeze SDK API evidence before publish

* fix(ci): satisfy SDK evidence guards

* fix(release): bind complete SDK evidence

* fix(release): authenticate plugin SDK evidence

* fix(plugin-sdk): abort interrupted API diffs

* test(ui): freeze page clock in background-tasks rail e2e

The rail transcript is compared byte-for-byte across the detail-panel
round-trip while it renders live relative ages; on slow CI runners the
second boundary ticks between the two reads (11s -> 12s) and fails the
equality assertion. Fix the page Date with Playwright setFixedTime while
keeping timers running so the tasks.list polling assertions still hold.

Repro: a 1.5s stall between the reads fails pre-fix with the exact CI
diff and passes post-fix.

* fix(scripts): drop unused export on dependency-evidence CLI main

Knip's workflow scan re-roots script references after an actions/checkout
step that sets path:, so the new trusted-tooling checkout in
openclaw-npm-release.yml stops marking this CLI as a workflow entry and
its exported main() surfaces as an unused export in check-dependencies.
Nothing imports main; the module invokes it through its own entry guard,
so the export keyword was dead surface either way.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-13 03:45:36 -07:00

409 lines
13 KiB
TypeScript

// API baseline helpers render public SDK exports for contract drift reports.
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 { publicPluginSdkEntrypoints } from "../../scripts/lib/plugin-sdk-entries.mts";
import {
createDeclarationClosureRenderer,
formatPluginSdkDiagnostics,
type PluginSdkApiDeclarationSection,
} from "./api-baseline-declaration-closure.js";
import { printPluginSdkExportDeclaration } from "./api-baseline-declaration-print.js";
import { normalizePluginSdkApiSourcePath as relativePath } from "./api-baseline-normalization.js";
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;
/** References into the baseline's deduplicated declaration section pool. */
closureSectionIds: number[] | 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 SDK API surface payload. */
export type PluginSdkApiBaseline = {
/** Deduplicated repo-owned declarations reachable from public exports. */
declarationSections: PluginSdkApiDeclarationSection[];
/** Public SDK modules included in the baseline. */
modules: PluginSdkApiModule[];
};
type RenderedPluginSdkApiExport = Omit<PluginSdkApiExport, "closureSectionIds"> & {
closureSections: PluginSdkApiDeclarationSection[] | null;
};
type RenderedPluginSdkApiModule = Omit<PluginSdkApiModule, "exports"> & {
exports: RenderedPluginSdkApiExport[];
};
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;
}): RenderedPluginSdkApiExport {
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();
const closure =
declarationSource && declarationText
? declarationClosure(declarationSource, closureName)
: null;
return {
closureHash: closure?.hash ?? null,
closureSections: closure?.sections ?? 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: RenderedPluginSdkApiExport, right: RenderedPluginSdkApiExport): 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;
}): RenderedPluginSdkApiModule {
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) },
};
}
/** Render a public SDK API surface without writing generated artifacts. */
export async function renderPluginSdkApiBaseline(params?: {
repoRoot?: string;
entrypoints?: readonly string[];
}): Promise<PluginSdkApiBaseline> {
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
const entrypoints = params?.entrypoints ?? listPluginSdkApiBaselineEntrypoints();
if (params?.entrypoints === undefined) {
validateMetadata();
}
const { checker, declarationClosure, printer, program } = createCompilerContext(
repoRoot,
entrypoints,
);
const modules = [...entrypoints].toSorted(compareText).map((entrypoint) =>
buildModuleSurface({
checker,
declarationClosure,
printer,
program,
repoRoot,
entrypoint,
}),
);
const declarationSections = [
...new Map(
modules.flatMap((moduleSurface) =>
moduleSurface.exports.flatMap((exportSurface) =>
(exportSurface.closureSections ?? []).map((section) => [
`${section.name}\0${section.text}`,
section,
]),
),
),
).values(),
].toSorted(
(left, right) => compareText(left.name, right.name) || compareText(left.text, right.text),
);
const sectionIds = new Map(
declarationSections.map((section, index) => [`${section.name}\0${section.text}`, index]),
);
return {
declarationSections,
modules: modules
.map((moduleSurface) => ({
category: moduleSurface.category,
entrypoint: moduleSurface.entrypoint,
exports: moduleSurface.exports.map((exportSurface) => ({
closureHash: exportSurface.closureHash,
closureSectionIds:
exportSurface.closureSections?.map((section) => {
const id = sectionIds.get(`${section.name}\0${section.text}`);
assert(id !== undefined, "Missing Plugin SDK declaration section");
return id;
}) ?? null,
declaration: exportSurface.declaration,
exportName: exportSurface.exportName,
kind: exportSurface.kind,
source: exportSurface.source,
})),
importSpecifier: moduleSurface.importSpecifier,
source: moduleSurface.source,
}))
.toSorted((left, right) => compareText(left.importSpecifier, right.importSpecifier)),
};
}
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.`,
);
}
}