Files
openclaw/src/plugin-sdk/api-diff.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

559 lines
19 KiB
TypeScript

import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { PluginSdkApiDeclarationSection } from "./api-baseline-declaration-closure.js";
import { renderPluginSdkApiBaseline, type PluginSdkApiExport } from "./api-baseline.js";
const ENTRYPOINTS_PATH = "scripts/lib/plugin-sdk-entrypoints.json";
const PRIVATE_ENTRYPOINTS_PATH = "scripts/lib/plugin-sdk-private-local-only-subpaths.json";
const REPORT_ITEM_LIMIT = 40;
const REPORT_TEXT_LINE_LIMIT = 20;
const REPORT_AFFECTED_EXPORT_LIMIT = 5;
const REPORT_BYTE_LIMIT = 64 * 1024;
export type PluginSdkApiExportSnapshot = Pick<
PluginSdkApiExport,
"closureHash" | "declaration" | "kind"
>;
type PluginSdkApiDiffExport = Pick<
PluginSdkApiExport,
"closureHash" | "closureSectionIds" | "declaration" | "exportName" | "kind"
>;
type PluginSdkApiDiffModule = {
entrypoint: string;
exports: PluginSdkApiDiffExport[];
importSpecifier: string;
};
export type PluginSdkApiDiffSurface = {
declarationSections: PluginSdkApiDeclarationSection[];
modules: PluginSdkApiDiffModule[];
};
export type PluginSdkApiDeclarationChange = {
after: string | null;
before: string | null;
name: string;
};
export type PluginSdkApiExportChange = {
after: PluginSdkApiExportSnapshot | null;
before: PluginSdkApiExportSnapshot | null;
change: "added" | "reachable" | "removed" | "signature";
declarationChanges: PluginSdkApiDeclarationChange[];
entrypoint: string;
exportName: string;
importSpecifier: string;
};
export type PluginSdkApiEntrypointChange = {
entrypoint: string;
exportNames: string[];
importSpecifier: string;
};
export type PluginSdkApiDiffPayload = {
entrypointsAdded: PluginSdkApiEntrypointChange[];
entrypointsRemoved: PluginSdkApiEntrypointChange[];
exports: PluginSdkApiExportChange[];
};
export type PluginSdkApiDiff = PluginSdkApiDiffPayload & {
digest: string;
};
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
async function readStringList(repoRoot: string, relativePath: string): Promise<string[]> {
const filePath = path.join(repoRoot, relativePath);
const parsed: unknown = JSON.parse(await fs.readFile(filePath, "utf8"));
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
throw new Error(`${relativePath} must contain a JSON string array`);
}
return [...parsed];
}
/** Read the public entrypoint inventory owned by one repository revision. */
export async function readPluginSdkApiEntrypoints(repoRoot: string): Promise<string[]> {
const [entrypoints, privateSubpaths] = await Promise.all([
readStringList(repoRoot, ENTRYPOINTS_PATH),
readStringList(repoRoot, PRIVATE_ENTRYPOINTS_PATH),
]);
const privateEntrypoints = new Set(privateSubpaths.filter((entry) => !entry.includes("/")));
return entrypoints.filter((entrypoint) => !privateEntrypoints.has(entrypoint));
}
/** Render the public SDK surface using the inventory from that same repository revision. */
export async function renderPluginSdkApiRoot(repoRoot: string): Promise<PluginSdkApiDiffSurface> {
return renderPluginSdkApiBaseline({
entrypoints: await readPluginSdkApiEntrypoints(repoRoot),
repoRoot,
});
}
function readNullableString(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
if (value !== null && typeof value !== "string") {
throw new Error(`Plugin SDK API render has invalid ${key}`);
}
return value;
}
function parseSections(value: unknown): PluginSdkApiDeclarationSection[] {
if (!Array.isArray(value)) {
throw new Error("Plugin SDK API render has invalid declarationSections");
}
return value.map((section) => {
if (
!isRecord(section) ||
typeof section.name !== "string" ||
typeof section.text !== "string"
) {
throw new Error("Plugin SDK API render has invalid declaration section");
}
return { name: section.name, text: section.text };
});
}
function parseSectionIds(value: unknown, sectionCount: number): number[] | null {
if (value === null) {
return null;
}
if (!Array.isArray(value)) {
throw new Error("Plugin SDK API render has invalid closureSectionIds");
}
return value.map((id) => {
if (typeof id !== "number" || !Number.isInteger(id) || id < 0 || id >= sectionCount) {
throw new Error("Plugin SDK API render has invalid closureSectionIds");
}
return id;
});
}
function isExportKind(value: unknown): value is PluginSdkApiExport["kind"] {
return (
value === "class" ||
value === "const" ||
value === "enum" ||
value === "function" ||
value === "interface" ||
value === "namespace" ||
value === "type" ||
value === "unknown" ||
value === "variable"
);
}
/** Validate and project the renderer artifact consumed across the subprocess boundary. */
export function parsePluginSdkApiDiffSurface(content: string): PluginSdkApiDiffSurface {
const parsed: unknown = JSON.parse(content);
if (!isRecord(parsed) || !Array.isArray(parsed.modules)) {
throw new Error("Plugin SDK API render has invalid modules");
}
const declarationSections = parseSections(parsed.declarationSections);
return {
declarationSections,
modules: parsed.modules.map((moduleSurface) => {
if (
!isRecord(moduleSurface) ||
typeof moduleSurface.entrypoint !== "string" ||
typeof moduleSurface.importSpecifier !== "string" ||
!Array.isArray(moduleSurface.exports)
) {
throw new Error("Plugin SDK API render has invalid module");
}
return {
entrypoint: moduleSurface.entrypoint,
importSpecifier: moduleSurface.importSpecifier,
exports: moduleSurface.exports.map((exportSurface) => {
if (
!isRecord(exportSurface) ||
typeof exportSurface.exportName !== "string" ||
!isExportKind(exportSurface.kind)
) {
throw new Error("Plugin SDK API render has invalid export");
}
return {
closureHash: readNullableString(exportSurface, "closureHash"),
closureSectionIds: parseSectionIds(
exportSurface.closureSectionIds,
declarationSections.length,
),
declaration: readNullableString(exportSurface, "declaration"),
exportName: exportSurface.exportName,
kind: exportSurface.kind,
};
}),
};
}),
};
}
function snapshot(
exportSurface: PluginSdkApiDiffExport | undefined,
): PluginSdkApiExportSnapshot | null {
return exportSurface
? {
closureHash: exportSurface.closureHash,
declaration: exportSurface.declaration,
kind: exportSurface.kind,
}
: null;
}
function sectionKey(section: PluginSdkApiDeclarationSection): string {
return `${section.name}\0${section.text}`;
}
function collectDeclarationChanges(
before: readonly PluginSdkApiDeclarationSection[],
after: readonly PluginSdkApiDeclarationSection[],
): PluginSdkApiDeclarationChange[] {
const beforeByKey = new Map((before ?? []).map((section) => [sectionKey(section), section]));
const afterByKey = new Map((after ?? []).map((section) => [sectionKey(section), section]));
const removedByName = new Map<string, string[]>();
const addedByName = new Map<string, string[]>();
for (const [key, section] of beforeByKey) {
if (!afterByKey.has(key)) {
removedByName.set(section.name, [...(removedByName.get(section.name) ?? []), section.text]);
}
}
for (const [key, section] of afterByKey) {
if (!beforeByKey.has(key)) {
addedByName.set(section.name, [...(addedByName.get(section.name) ?? []), section.text]);
}
}
const names = new Set([...removedByName.keys(), ...addedByName.keys()]);
const changes: PluginSdkApiDeclarationChange[] = [];
for (const name of [...names].toSorted(compareText)) {
const removed = (removedByName.get(name) ?? []).toSorted(compareText);
const added = (addedByName.get(name) ?? []).toSorted(compareText);
const count = Math.max(removed.length, added.length);
for (let index = 0; index < count; index += 1) {
changes.push({
after: added[index] ?? null,
before: removed[index] ?? null,
name,
});
}
}
return changes;
}
function exportSections(
surface: PluginSdkApiDiffSurface,
exportSurface: PluginSdkApiDiffExport | undefined,
): PluginSdkApiDeclarationSection[] {
if (!exportSurface) {
return [];
}
return (exportSurface.closureSectionIds ?? []).map((id) => {
const section = surface.declarationSections[id];
if (!section) {
throw new Error(`Plugin SDK API render references missing declaration section ${id}`);
}
return section;
});
}
function moduleChange(moduleSurface: PluginSdkApiDiffModule): PluginSdkApiEntrypointChange {
return {
entrypoint: moduleSurface.entrypoint,
exportNames: moduleSurface.exports.map((exportSurface) => exportSurface.exportName).toSorted(),
importSpecifier: moduleSurface.importSpecifier,
};
}
function exportMap(moduleSurface: PluginSdkApiDiffModule): Map<string, PluginSdkApiDiffExport> {
return new Map(
moduleSurface.exports.map((exportSurface) => [exportSurface.exportName, exportSurface]),
);
}
/** Compare two rendered SDK surfaces without consulting committed approval state. */
export function diffPluginSdkApi(
before: PluginSdkApiDiffSurface,
after: PluginSdkApiDiffSurface,
): PluginSdkApiDiff {
const beforeModules = new Map(
before.modules.map((moduleSurface) => [moduleSurface.entrypoint, moduleSurface]),
);
const afterModules = new Map(
after.modules.map((moduleSurface) => [moduleSurface.entrypoint, moduleSurface]),
);
const entrypoints = new Set([...beforeModules.keys(), ...afterModules.keys()]);
const payload: PluginSdkApiDiffPayload = {
entrypointsAdded: [],
entrypointsRemoved: [],
exports: [],
};
for (const entrypoint of [...entrypoints].toSorted(compareText)) {
const beforeModule = beforeModules.get(entrypoint);
const afterModule = afterModules.get(entrypoint);
if (!beforeModule && afterModule) {
payload.entrypointsAdded.push(moduleChange(afterModule));
}
if (beforeModule && !afterModule) {
payload.entrypointsRemoved.push(moduleChange(beforeModule));
}
const moduleSurface = afterModule ?? beforeModule;
if (!moduleSurface) {
throw new Error(`Plugin SDK API diff lost entrypoint ${entrypoint}`);
}
const beforeExports = beforeModule ? exportMap(beforeModule) : new Map();
const afterExports = afterModule ? exportMap(afterModule) : new Map();
const exportNames = new Set([...beforeExports.keys(), ...afterExports.keys()]);
for (const exportName of [...exportNames].toSorted(compareText)) {
const beforeExport = beforeExports.get(exportName);
const afterExport = afterExports.get(exportName);
if (!beforeExport && afterExport) {
payload.exports.push({
after: snapshot(afterExport),
before: null,
change: "added",
declarationChanges: collectDeclarationChanges([], exportSections(after, afterExport)),
entrypoint,
exportName,
importSpecifier: moduleSurface.importSpecifier,
});
continue;
}
if (beforeExport && !afterExport) {
payload.exports.push({
after: null,
before: snapshot(beforeExport),
change: "removed",
declarationChanges: collectDeclarationChanges(exportSections(before, beforeExport), []),
entrypoint,
exportName,
importSpecifier: moduleSurface.importSpecifier,
});
continue;
}
if (!beforeExport || !afterExport) {
continue;
}
if (
beforeExport.kind !== afterExport.kind ||
beforeExport.declaration !== afterExport.declaration
) {
payload.exports.push({
after: snapshot(afterExport),
before: snapshot(beforeExport),
change: "signature",
declarationChanges: collectDeclarationChanges(
exportSections(before, beforeExport),
exportSections(after, afterExport),
),
entrypoint,
exportName,
importSpecifier: moduleSurface.importSpecifier,
});
} else if (beforeExport.closureHash !== afterExport.closureHash) {
payload.exports.push({
after: snapshot(afterExport),
before: snapshot(beforeExport),
change: "reachable",
declarationChanges: collectDeclarationChanges(
exportSections(before, beforeExport),
exportSections(after, afterExport),
),
entrypoint,
exportName,
importSpecifier: moduleSurface.importSpecifier,
});
}
}
}
return {
...payload,
digest: createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex"),
};
}
export function hasPluginSdkApiChanges(diff: PluginSdkApiDiff): boolean {
return (
diff.entrypointsAdded.length > 0 ||
diff.entrypointsRemoved.length > 0 ||
diff.exports.length > 0
);
}
export function pluginSdkApiAcknowledgement(diff: PluginSdkApiDiff): string {
return diff.digest.slice(0, 8);
}
function appendText(lines: string[], label: "after" | "before", text: string | null): void {
lines.push(` ${label}:`);
const textLines = (text ?? "declaration unavailable").split("\n");
for (const line of textLines.slice(0, REPORT_TEXT_LINE_LIMIT)) {
lines.push(` ${line}`);
}
if (textLines.length > REPORT_TEXT_LINE_LIMIT) {
lines.push(` … ${textLines.length - REPORT_TEXT_LINE_LIMIT} more lines`);
}
}
function truncateUtf8(text: string, maxBytes: number): string {
const bytes = Buffer.from(text, "utf8");
if (bytes.length <= maxBytes) {
return text;
}
let end = maxBytes;
while (end > 0) {
const excludedByte = bytes[end];
if (excludedByte === undefined || (excludedByte & 0xc0) !== 0x80) {
break;
}
end -= 1;
}
return bytes.subarray(0, end).toString("utf8");
}
function appendExportChanges(
lines: string[],
title: string,
changes: readonly PluginSdkApiExportChange[],
): void {
if (changes.length === 0) {
return;
}
lines.push("", `## ${title} (${changes.length})`);
for (const change of changes.slice(0, REPORT_ITEM_LIMIT)) {
lines.push("", `- \`${change.importSpecifier}\` — \`${change.exportName}\``);
if (change.change === "signature") {
appendText(lines, "before", change.before?.declaration ?? null);
appendText(lines, "after", change.after?.declaration ?? null);
} else {
appendText(
lines,
change.change === "removed" ? "before" : "after",
change.before?.declaration ?? change.after?.declaration ?? null,
);
}
}
if (changes.length > REPORT_ITEM_LIMIT) {
lines.push("", `… ${changes.length - REPORT_ITEM_LIMIT} more`);
}
}
type DeclarationReportChange = PluginSdkApiDeclarationChange & { affectedExports: string[] };
function collectDeclarationReportChanges(
changes: readonly PluginSdkApiExportChange[],
): DeclarationReportChange[] {
const grouped = new Map<string, DeclarationReportChange>();
for (const change of changes) {
const affectedExport = `${change.importSpecifier} :: ${change.exportName}`;
for (const declaration of change.declarationChanges) {
const key = `${declaration.name}\0${declaration.before ?? ""}\0${declaration.after ?? ""}`;
const current = grouped.get(key);
if (current) {
current.affectedExports.push(affectedExport);
} else {
grouped.set(key, { ...declaration, affectedExports: [affectedExport] });
}
}
}
return [...grouped.values()].toSorted(
(left, right) =>
compareText(left.name, right.name) ||
compareText(left.before ?? "", right.before ?? "") ||
compareText(left.after ?? "", right.after ?? ""),
);
}
/** Format a bounded PR/release summary. The full machine-readable diff stays in JSON. */
export function formatPluginSdkApiDiffReport(params: {
baseLabel: string;
diff: PluginSdkApiDiff;
headLabel: string;
}): string {
const { baseLabel, diff, headLabel } = params;
const lines = [
"# Plugin SDK API diff",
"",
`\`${baseLabel}\` → \`${headLabel}\``,
"",
`Acknowledgement digest: \`${pluginSdkApiAcknowledgement(diff)}\``,
];
if (!hasPluginSdkApiChanges(diff)) {
lines.push("", "No Plugin SDK API changes.");
return `${lines.join("\n")}\n`;
}
for (const [title, entrypoints] of [
["Entrypoints removed", diff.entrypointsRemoved],
["Entrypoints added", diff.entrypointsAdded],
] as const) {
if (entrypoints.length > 0) {
lines.push("", `## ${title} (${entrypoints.length})`, "");
for (const entrypoint of entrypoints) {
lines.push(
`- \`${entrypoint.importSpecifier}\` (${entrypoint.exportNames.length} exports)`,
);
}
}
}
appendExportChanges(
lines,
"Exports removed",
diff.exports.filter((change) => change.change === "removed"),
);
appendExportChanges(
lines,
"Exports added",
diff.exports.filter((change) => change.change === "added"),
);
appendExportChanges(
lines,
"Signatures changed",
diff.exports.filter((change) => change.change === "signature"),
);
const reachable = collectDeclarationReportChanges(diff.exports);
if (reachable.length > 0) {
const affectedCount = diff.exports.filter(
(change) => change.declarationChanges.length > 0,
).length;
lines.push(
"",
`## Reachable declarations changed (${reachable.length}; affects ${affectedCount} exports)`,
);
for (const change of reachable.slice(0, REPORT_ITEM_LIMIT)) {
lines.push("", `- \`${change.name}\``);
appendText(lines, "before", change.before);
appendText(lines, "after", change.after);
const affected = change.affectedExports.toSorted(compareText);
lines.push(
` affects: ${affected
.slice(0, REPORT_AFFECTED_EXPORT_LIMIT)
.map((value) => `\`${value}\``)
.join(
", ",
)}${affected.length > REPORT_AFFECTED_EXPORT_LIMIT ? ` (+${affected.length - REPORT_AFFECTED_EXPORT_LIMIT} more)` : ""}`,
);
}
if (reachable.length > REPORT_ITEM_LIMIT) {
lines.push("", `… ${reachable.length - REPORT_ITEM_LIMIT} more reachable declarations`);
}
}
const report = `${lines.join("\n")}\n`;
if (Buffer.byteLength(report, "utf8") <= REPORT_BYTE_LIMIT) {
return report;
}
const suffix = "\n\n… summary truncated; inspect the JSON artifact.\n";
return `${truncateUtf8(report, REPORT_BYTE_LIMIT - Buffer.byteLength(suffix, "utf8"))}${suffix}`;
}