fix(plugin-sdk): cover the full public API baseline

This commit is contained in:
Vincent Koc
2026-07-12 05:30:20 +02:00
parent 350bac4e0e
commit ddbec923d4
7 changed files with 407 additions and 82 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Plugin SDK API baseline:** cover every public entrypoint, preserve complete declaration shapes without source-line churn, and run baseline and export-surface guards from changed-file validation.
- **SQLite terminal session recovery:** track physical transcript mutation time in the agent database so killed or timed-out main sessions rotate when transcript writes outlive the registry update, while preserving legacy transcript mtimes during doctor import.
- **Gateway chat typecheck:** import chat event types from their owning protocol schema after the retired aggregate type module was removed, restoring full project typechecks.
- **Packaged Crabbox commands:** include the lease-freshness helper imported by the published wrapper so `crabbox:*` commands do not fail with `ERR_MODULE_NOT_FOUND` in npm installs.

View File

@@ -1,2 +1,2 @@
0597218863ded2b9e86ab56773c5d92b9bd6dcb5e5d537e8e83397fcbb56a5d5 plugin-sdk-api-baseline.json
6d7406435ac48216be84c10d216703c0459577762899d20c5921993e9f1e3478 plugin-sdk-api-baseline.jsonl
af04c051b9697ffc27468c0948aa51c122366e7b97b93cf66f19b1b87f846caf plugin-sdk-api-baseline.json
7dbae052b16721a5af5c011d1add527af028cc8f32dc84f2dfc4534004e22d69 plugin-sdk-api-baseline.jsonl

View File

@@ -40,6 +40,8 @@ export function shouldRunPromptSnapshotCheck(paths: string[]): boolean;
export function shouldRunPromptSnapshotOwnerTest(paths: string[]): boolean;
export function shouldRunRuntimeSidecarBaselineCheck(paths: string[]): boolean;
export function shouldRunSqliteSessionSchemaBaselineCheck(paths: string[]): boolean;
export function shouldRunPluginSdkApiBaselineCheck(paths: string[]): boolean;
export function shouldRunPluginSdkSurfaceChecks(paths: string[]): boolean;
export function shouldRunCanvasA2uiNativeResourceCheck(paths: string[]): boolean;
export function shouldRunAppcastOwnerTest(paths: string[]): boolean;
export function shouldRunTestTempCreationReport(paths: string[]): boolean;

View File

@@ -39,6 +39,10 @@ const RUNTIME_SIDECAR_BASELINE_PATH_RE =
/^(?:scripts\/generate-runtime-sidecar-paths-baseline\.ts|scripts\/lib\/bundled-runtime-sidecar-paths\.json|src\/plugins\/runtime-sidecar-paths(?:-baseline)?\.ts)$/u;
const SQLITE_SESSION_SCHEMA_BASELINE_PATH_RE =
/^(?:src\/state\/openclaw-agent-schema\.sql|scripts\/(?:generate-sqlite-session-schema-baseline\.ts|lib\/sqlite-session-schema-baseline\.ts)|test\/scripts\/sqlite-session-schema-baseline\.test\.ts|docs\/\.generated\/sqlite-session-transcript-schema-baseline\.sha256)$/u;
const PLUGIN_SDK_API_BASELINE_PATH_RE =
/^(?:src\/|packages\/|extensions\/|pnpm-lock\.yaml$|tsconfig\.json$|scripts\/(?:generate-plugin-sdk-api-baseline\.ts|lib\/plugin-sdk-(?:doc-metadata\.ts|entries\.mjs|entrypoints\.json|private-local-only-subpaths\.json))|docs\/\.generated\/plugin-sdk-api-baseline\.sha256$)/u;
const PLUGIN_SDK_SURFACE_PATH_RE =
/^(?:package\.json$|src\/plugin-sdk\/|scripts\/(?:plugin-sdk-surface-report\.mjs|sync-plugin-sdk-exports\.mjs|lib\/plugin-sdk-(?:declaration-budget\.mjs|deprecated-barrel-subpaths\.json|deprecated-public-subpaths\.json|entries\.mjs|entrypoints\.json|private-local-only-subpaths\.json)))/u;
const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE =
/^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u;
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
@@ -208,6 +212,24 @@ export function shouldRunSqliteSessionSchemaBaselineCheck(paths) {
);
}
/** Returns whether changed files can alter the published Plugin SDK API contract. */
export function shouldRunPluginSdkApiBaselineCheck(paths) {
return paths.some((changedPath) => {
const normalizedPath = normalizeChangedPath(changedPath);
return (
!getChangedPathFacts(normalizedPath).isTestOnly &&
PLUGIN_SDK_API_BASELINE_PATH_RE.test(normalizedPath)
);
});
}
/** Returns whether changed files can alter Plugin SDK exports or surface budgets. */
export function shouldRunPluginSdkSurfaceChecks(paths) {
return paths.some((changedPath) =>
PLUGIN_SDK_SURFACE_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
export function shouldRunCanvasA2uiNativeResourceCheck(paths) {
return paths.some((changedPath) =>
CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE.test(normalizeChangedPath(changedPath)),
@@ -335,6 +357,13 @@ export function createChangedCheckPlan(result, options = {}) {
if (shouldRunSqliteSessionSchemaBaselineCheck(result.paths)) {
add("SQLite sessions/transcripts schema baseline", ["sqlite:sessions-schema:check"]);
}
if (shouldRunPluginSdkApiBaselineCheck(result.paths)) {
add("Plugin SDK API baseline", ["plugin-sdk:api:check"]);
}
if (!result.lanes.releaseMetadata && shouldRunPluginSdkSurfaceChecks(result.paths)) {
add("Plugin SDK package exports", ["plugin-sdk:check-exports"]);
add("Plugin SDK surface budget", ["plugin-sdk:surface:check"]);
}
if (shouldRunCanvasA2uiNativeResourceCheck(result.paths)) {
addCommand(
"Canvas A2UI native resource sync",

View File

@@ -3,9 +3,12 @@
*/
import path from "node:path";
import { describe, expect, it } from "vitest";
import { publicPluginSdkEntrypoints } from "../../scripts/lib/plugin-sdk-entries.mjs";
import {
listPluginSdkApiBaselineEntrypoints,
normalizePluginSdkApiDeclarationText,
normalizePluginSdkApiSourcePath,
renderPluginSdkApiBaseline,
} from "./api-baseline.js";
describe("Plugin SDK API baseline", () => {
@@ -60,4 +63,59 @@ describe("Plugin SDK API baseline", () => {
expect(normalizePluginSdkApiSourcePath(repoRoot, sourcePath)).toBe("src/plugin-sdk/core.ts");
});
it("renders complete declarations for the canonical public entrypoint inventory", async () => {
expect(listPluginSdkApiBaselineEntrypoints()).toEqual(publicPluginSdkEntrypoints);
const rendered = await renderPluginSdkApiBaseline({
entrypoints: [
"agent-harness-runtime",
"approval-gateway-runtime",
"infra-runtime",
"provider-catalog-live-runtime",
"provider-oauth-runtime",
"provider-selection-runtime",
"provider-web-search-config-contract",
"realtime-voice",
"sqlite-runtime-testing",
],
});
const findDeclaration = (exportName: string) =>
rendered.baseline.modules
.flatMap((moduleSurface) => moduleSurface.exports)
.find((exportSurface) => exportSurface.exportName === exportName)?.declaration;
expect(rendered.baseline.modules.find((entry) => entry.entrypoint === "infra-runtime")).toEqual(
expect.objectContaining({
category: null,
importSpecifier: "openclaw/plugin-sdk/infra-runtime",
}),
);
expect(findDeclaration("OAuthProviderInterface")).toContain("readonly id: OAuthProviderId;");
expect(findDeclaration("OAuthProviderInterface")).toContain(
"login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;",
);
expect(findDeclaration("LiveModelCatalogHttpError")).toContain("readonly status: number;");
expect(findDeclaration("LiveModelCatalogHttpError")).toContain(
"constructor(providerId: string, status: number);",
);
expect(findDeclaration("LiveModelCatalogHttpError")).not.toContain("super(");
expect(findDeclaration("ApprovalResolveResult")).not.toContain("see source");
expect(findDeclaration("RealtimeVoiceAgentConsultRuntime")).not.toContain("see source");
expect(findDeclaration("createWebSearchProviderContractFields")).toContain(
"export function createWebSearchProviderContractFields(",
);
expect(findDeclaration("createWebSearchProviderContractFields")).not.toContain(
"createBaseWebSearchProviderContractFields",
);
expect(findDeclaration("OPENCLAW_VERSION")).toContain("export const OPENCLAW_VERSION:");
expect(findDeclaration("SqliteTrajectoryRuntimeEventForTest")).toContain(
"export type SqliteTrajectoryRuntimeEventForTest =",
);
expect(findDeclaration("ProviderSelection")).toContain(
"export type ProviderSelection<TProvider> =",
);
expect(rendered.json).not.toContain('"line":');
expect(rendered.jsonl).not.toContain('"sourceLine":');
});
});

View File

@@ -3,11 +3,9 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import ts from "typescript";
import {
pluginSdkDocMetadata,
resolvePluginSdkDocImportSpecifier,
type PluginSdkDocCategory,
type PluginSdkDocEntrypoint,
} from "../../scripts/lib/plugin-sdk-doc-metadata.ts";
@@ -27,8 +25,6 @@ export type PluginSdkApiExportKind =
/** Repo source location for a public SDK declaration or module. */
export type PluginSdkApiSourceLink = {
/** One-based source line for docs and review links. */
line: number;
/** Repo-relative source file path. */
path: string;
};
@@ -47,10 +43,10 @@ export type PluginSdkApiExport = {
/** API baseline record for one public SDK module/subpath. */
export type PluginSdkApiModule = {
/** Documentation category used to group SDK entrypoints. */
category: PluginSdkDocCategory;
/** Entry point metadata from the SDK docs registry. */
entrypoint: PluginSdkDocEntrypoint;
/** 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. */
@@ -160,7 +156,7 @@ export function normalizePluginSdkApiDeclarationText(repoRoot: string, value: st
);
}
function createCompilerContext(repoRoot: string) {
function createCompilerContext(repoRoot: string, entrypoints: readonly string[]) {
const configPath = ts.findConfigFile(
repoRoot,
(filePath) => ts.sys.fileExists(filePath),
@@ -172,31 +168,32 @@ function createCompilerContext(repoRoot: string) {
throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
}
const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot);
const fileNames = parsedConfig.fileNames.toSorted((left, right) =>
compareText(
relativePath(repoRoot, path.resolve(left)),
relativePath(repoRoot, path.resolve(right)),
),
);
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);
return {
checker: program.getTypeChecker(),
printer: ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }),
printer: ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,
removeComments: true,
}),
program,
};
}
function buildSourceLink(
repoRoot: string,
program: ts.Program,
filePath: string,
start: number,
): PluginSdkApiSourceLink {
const sourceFile = program.getSourceFile(filePath);
assert(sourceFile, `Unable to read source file for ${relativePath(repoRoot, filePath)}`);
const line = sourceFile.getLineAndCharacterOfPosition(start).line + 1;
/** List canonical public SDK entrypoints included in the API baseline. */
export function listPluginSdkApiBaselineEntrypoints(): string[] {
return [...publicPluginSdkEntrypoints];
}
function buildSourceLink(repoRoot: string, filePath: string): PluginSdkApiSourceLink {
return {
line,
path: relativePath(repoRoot, filePath),
};
}
@@ -278,30 +275,219 @@ function resolveSymbolAndDeclaration(
return { declaration, resolvedSymbol };
}
const DECLARATION_TYPE_FORMAT_FLAGS =
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.MultilineObjectLiterals;
const DECLARATION_NODE_BUILDER_FLAGS = ts.NodeBuilderFlags.NoTruncation;
function declarationModifiers(node: ts.Node): readonly ts.Modifier[] | undefined {
return ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
}
function inferDeclarationTypeNode(
checker: ts.TypeChecker,
declaration: ts.Declaration,
explicitType: ts.TypeNode | undefined,
): ts.TypeNode | undefined {
return (
explicitType ??
checker.typeToTypeNode(
checker.getTypeAtLocation(declaration),
declaration,
DECLARATION_NODE_BUILDER_FLAGS,
)
);
}
function inferDeclarationReturnTypeNode(
checker: ts.TypeChecker,
declaration: ts.SignatureDeclaration,
explicitType: ts.TypeNode | undefined,
): ts.TypeNode | undefined {
if (explicitType) {
return explicitType;
}
const signature = checker.getSignatureFromDeclaration(declaration);
return signature
? checker.typeToTypeNode(
checker.getReturnTypeOfSignature(signature),
declaration,
DECLARATION_NODE_BUILDER_FLAGS,
)
: undefined;
}
function stripParameterInitializer(parameter: ts.ParameterDeclaration): ts.ParameterDeclaration {
return ts.factory.updateParameterDeclaration(
parameter,
declarationModifiers(parameter),
parameter.dotDotDotToken,
parameter.name,
parameter.questionToken,
parameter.type,
undefined,
);
}
function stripClassMemberImplementation(
checker: ts.TypeChecker,
member: ts.ClassElement,
): ts.ClassElement | null {
if (ts.isClassStaticBlockDeclaration(member)) {
return null;
}
if (ts.isConstructorDeclaration(member)) {
return ts.factory.updateConstructorDeclaration(
member,
declarationModifiers(member),
member.parameters.map(stripParameterInitializer),
undefined,
);
}
if (ts.isMethodDeclaration(member)) {
return ts.factory.updateMethodDeclaration(
member,
declarationModifiers(member),
member.asteriskToken,
member.name,
member.questionToken,
member.typeParameters,
member.parameters.map(stripParameterInitializer),
inferDeclarationReturnTypeNode(checker, member, member.type),
undefined,
);
}
if (ts.isGetAccessorDeclaration(member)) {
return ts.factory.updateGetAccessorDeclaration(
member,
declarationModifiers(member),
member.name,
member.parameters.map(stripParameterInitializer),
inferDeclarationReturnTypeNode(checker, member, member.type),
undefined,
);
}
if (ts.isSetAccessorDeclaration(member)) {
return ts.factory.updateSetAccessorDeclaration(
member,
declarationModifiers(member),
member.name,
member.parameters.map(stripParameterInitializer),
undefined,
);
}
if (ts.isPropertyDeclaration(member)) {
return ts.factory.updatePropertyDeclaration(
member,
declarationModifiers(member),
member.name,
member.questionToken ?? member.exclamationToken,
inferDeclarationTypeNode(checker, member, member.type),
undefined,
);
}
return member;
}
function stripClassImplementation(
checker: ts.TypeChecker,
declaration: ts.ClassDeclaration,
exportName: string,
): ts.ClassDeclaration {
const members = declaration.members.flatMap((member) => {
const stripped = stripClassMemberImplementation(checker, member);
return stripped ? [stripped] : [];
});
return ts.factory.updateClassDeclaration(
declaration,
declarationModifiers(declaration),
ts.factory.createIdentifier(exportName),
declaration.typeParameters,
declaration.heritageClauses,
members,
);
}
function renameStructuredDeclarationForExport(
checker: ts.TypeChecker,
declaration: ts.Declaration,
exportName: string,
): ts.Declaration {
const name = ts.factory.createIdentifier(exportName);
if (ts.isClassDeclaration(declaration)) {
return stripClassImplementation(checker, declaration, exportName);
}
if (ts.isInterfaceDeclaration(declaration)) {
return ts.factory.updateInterfaceDeclaration(
declaration,
declarationModifiers(declaration),
name,
declaration.typeParameters,
declaration.heritageClauses,
declaration.members,
);
}
if (ts.isEnumDeclaration(declaration)) {
return ts.factory.updateEnumDeclaration(
declaration,
declarationModifiers(declaration),
name,
declaration.members,
);
}
if (ts.isModuleDeclaration(declaration) && ts.isIdentifier(declaration.name)) {
return ts.factory.updateModuleDeclaration(
declaration,
declarationModifiers(declaration),
name,
declaration.body,
);
}
return declaration;
}
function ensureExportedDeclarationText(value: string): string {
return /^export\b/u.test(value) ? value : `export ${value}`;
}
function printTypeParameters(printer: ts.Printer, declaration: ts.TypeAliasDeclaration): string {
if (!declaration.typeParameters?.length) {
return "";
}
const sourceFile = declaration.getSourceFile();
const parameters = declaration.typeParameters.map((typeParameter) =>
printer.printNode(ts.EmitHint.Unspecified, typeParameter, sourceFile).trim(),
);
return `<${parameters.join(", ")}>`;
}
function printNode(
repoRoot: string,
checker: ts.TypeChecker,
printer: ts.Printer,
declaration: ts.Declaration,
exportName: string,
): string | null {
if (ts.isFunctionDeclaration(declaration)) {
const signatures = checker.getTypeAtLocation(declaration).getCallSignatures();
if (signatures.length === 0) {
return `export function ${declaration.name?.text ?? "anonymous"}();`;
return `export function ${exportName}();`;
}
return normalizePluginSdkApiDeclarationText(
repoRoot,
signatures
.map(
(signature) =>
`export function ${declaration.name?.text ?? "anonymous"}${checker.signatureToString(signature)};`,
`export function ${exportName}${checker.signatureToString(
signature,
declaration,
DECLARATION_TYPE_FORMAT_FLAGS,
)};`,
)
.join("\n"),
);
}
if (ts.isVariableDeclaration(declaration)) {
const name = declaration.name.getText();
const type = checker.getTypeAtLocation(declaration);
const prefix =
declaration.parent && (ts.getCombinedNodeFlags(declaration.parent) & ts.NodeFlags.Const) !== 0
@@ -309,52 +495,39 @@ function printNode(
: "let";
return normalizePluginSdkApiDeclarationText(
repoRoot,
`export ${prefix} ${name}: ${checker.typeToString(type, declaration, ts.TypeFormatFlags.NoTruncation)};`,
`export ${prefix} ${exportName}: ${checker.typeToString(
type,
declaration,
DECLARATION_TYPE_FORMAT_FLAGS,
)};`,
);
}
if (ts.isInterfaceDeclaration(declaration)) {
return `export interface ${declaration.name.text}`;
}
if (ts.isClassDeclaration(declaration)) {
return `export class ${declaration.name?.text ?? "AnonymousClass"}`;
}
if (ts.isEnumDeclaration(declaration)) {
return `export enum ${declaration.name.text}`;
}
if (ts.isModuleDeclaration(declaration)) {
return `export namespace ${declaration.name.getText()}`;
}
if (ts.isTypeAliasDeclaration(declaration)) {
const type = checker.getTypeAtLocation(declaration);
const rendered = normalizePluginSdkApiDeclarationText(
const typeParameters = printTypeParameters(printer, declaration);
return normalizePluginSdkApiDeclarationText(
repoRoot,
`export type ${declaration.name.text} = ${checker.typeToString(
`export type ${exportName}${typeParameters} = ${checker.typeToString(
type,
declaration,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.MultilineObjectLiterals,
DECLARATION_TYPE_FORMAT_FLAGS,
)};`,
);
if (rendered.length > 1200) {
return `export type ${declaration.name.text} = /* see source */`;
}
return rendered;
}
const printableDeclaration = renameStructuredDeclarationForExport(
checker,
declaration,
exportName,
);
const text = printer
.printNode(ts.EmitHint.Unspecified, declaration, declaration.getSourceFile())
.printNode(ts.EmitHint.Unspecified, printableDeclaration, declaration.getSourceFile())
.trim();
if (!text) {
return null;
}
const normalizedText = normalizePluginSdkApiDeclarationText(repoRoot, text);
return normalizedText.length > 1200
? `${truncateUtf16Safe(normalizedText, 1175).trimEnd()}\n/* truncated; see source */`
: normalizedText;
return normalizePluginSdkApiDeclarationText(repoRoot, ensureExportedDeclarationText(text));
}
function compareText(left: string, right: string): number {
@@ -391,24 +564,19 @@ function compareDeclarations(
function buildExportSurface(params: {
checker: ts.TypeChecker;
printer: ts.Printer;
program: ts.Program;
repoRoot: string;
symbol: ts.Symbol;
}): PluginSdkApiExport {
const { checker, printer, program, repoRoot, symbol } = params;
const { checker, printer, repoRoot, symbol } = params;
const { declaration, resolvedSymbol } = resolveSymbolAndDeclaration(checker, repoRoot, symbol);
const exportName = symbol.getName();
return {
declaration: declaration ? printNode(repoRoot, checker, printer, declaration) : null,
exportName: symbol.getName(),
kind: inferExportKind(resolvedSymbol, declaration),
source: declaration
? buildSourceLink(
repoRoot,
program,
declaration.getSourceFile().fileName,
declaration.getStart(),
)
declaration: declaration
? printNode(repoRoot, checker, printer, declaration, exportName)
: null,
exportName,
kind: inferExportKind(resolvedSymbol, declaration),
source: declaration ? buildSourceLink(repoRoot, declaration.getSourceFile().fileName) : null,
};
}
@@ -437,11 +605,14 @@ function buildModuleSurface(params: {
printer: ts.Printer;
program: ts.Program;
repoRoot: string;
entrypoint: PluginSdkDocEntrypoint;
entrypoint: string;
}): PluginSdkApiModule {
const { checker, printer, program, repoRoot, entrypoint } = params;
const metadata = pluginSdkDocMetadata[entrypoint];
const importSpecifier = resolvePluginSdkDocImportSpecifier(entrypoint);
const metadata = Object.hasOwn(pluginSdkDocMetadata, entrypoint)
? pluginSdkDocMetadata[entrypoint as PluginSdkDocEntrypoint]
: undefined;
const importSpecifier =
entrypoint === "index" ? "openclaw/plugin-sdk" : `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}`);
@@ -456,7 +627,6 @@ function buildModuleSurface(params: {
buildExportSurface({
checker,
printer,
program,
repoRoot,
symbol,
}),
@@ -464,11 +634,11 @@ function buildModuleSurface(params: {
.toSorted(sortExports);
return {
category: metadata.category,
category: metadata?.category ?? null,
entrypoint,
exports,
importSpecifier,
source: buildSourceLink(repoRoot, program, moduleSourcePath, 0),
source: buildSourceLink(repoRoot, moduleSourcePath),
};
}
@@ -482,7 +652,6 @@ function buildJsonlLines(baseline: PluginSdkApiBaseline): string[] {
entrypoint: moduleSurface.entrypoint,
importSpecifier: moduleSurface.importSpecifier,
recordType: "module",
sourceLine: moduleSurface.source.line,
sourcePath: moduleSurface.source.path,
}),
);
@@ -496,7 +665,6 @@ function buildJsonlLines(baseline: PluginSdkApiBaseline): string[] {
importSpecifier: moduleSurface.importSpecifier,
kind: exportSurface.kind,
recordType: "export",
sourceLine: exportSurface.source?.line ?? null,
sourcePath: exportSurface.source?.path ?? null,
}),
);
@@ -509,11 +677,13 @@ function buildJsonlLines(baseline: PluginSdkApiBaseline): string[] {
/** 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, printer, program } = createCompilerContext(repoRoot);
const modules = (Object.keys(pluginSdkDocMetadata) as PluginSdkDocEntrypoint[])
const { checker, printer, program } = createCompilerContext(repoRoot, entrypoints);
const modules = entrypoints
.map((entrypoint) =>
buildModuleSurface({
checker,

View File

@@ -28,6 +28,8 @@ import {
shouldRunPromptSnapshotOwnerTest,
shouldRunRuntimeSidecarBaselineCheck,
shouldRunShrinkwrapGuard,
shouldRunPluginSdkApiBaselineCheck,
shouldRunPluginSdkSurfaceChecks,
shouldRunSqliteSessionSchemaBaselineCheck,
shouldRunTestTempCreationReport,
createShrinkwrapGuardCommand,
@@ -1700,6 +1702,69 @@ describe("scripts/changed-lanes", () => {
});
});
it("runs Plugin SDK API checks for transitive public contract changes", () => {
expect(
shouldRunPluginSdkApiBaselineCheck([
"src/config/sessions/session-accessor.ts",
"packages/gateway-protocol/src/schema/approvals.ts",
"extensions/memory-core/index.ts",
"scripts/generate-plugin-sdk-api-baseline.ts",
"scripts/lib/plugin-sdk-doc-metadata.ts",
"docs/.generated/plugin-sdk-api-baseline.sha256",
]),
).toBe(true);
expect(shouldRunPluginSdkApiBaselineCheck(["docs/help/troubleshooting.md"])).toBe(false);
const result = detectChangedLanes(["src/config/sessions/session-accessor.ts"]);
const plan = createChangedCheckPlan(result);
expect(plan.commands).toContainEqual({
name: "Plugin SDK API baseline",
args: ["plugin-sdk:api:check"],
});
expect(plan.commands.map((command) => command.args[0])).not.toContain(
"plugin-sdk:surface:check",
);
});
it("runs Plugin SDK export and surface checks for direct SDK changes", () => {
expect(
shouldRunPluginSdkSurfaceChecks([
"src/plugin-sdk/core.ts",
"scripts/plugin-sdk-surface-report.mjs",
"scripts/sync-plugin-sdk-exports.mjs",
"scripts/lib/plugin-sdk-entrypoints.json",
"package.json",
]),
).toBe(true);
expect(shouldRunPluginSdkSurfaceChecks(["src/config/sessions/session-accessor.ts"])).toBe(
false,
);
const result = detectChangedLanes(["src/plugin-sdk/core.ts"]);
const plan = createChangedCheckPlan(result);
expect(plan.commands).toContainEqual({
name: "Plugin SDK API baseline",
args: ["plugin-sdk:api:check"],
});
expect(plan.commands).toContainEqual({
name: "Plugin SDK package exports",
args: ["plugin-sdk:check-exports"],
});
expect(plan.commands).toContainEqual({
name: "Plugin SDK surface budget",
args: ["plugin-sdk:surface:check"],
});
const releaseMetadataPlan = createChangedCheckPlan(
detectChangedLanes(["CHANGELOG.md", "package.json"]),
);
expect(releaseMetadataPlan.commands.map((command) => command.args[0])).not.toContain(
"plugin-sdk:check-exports",
);
});
it("guards release metadata package changes to the top-level version field", () => {
const dir = makeTempRepoRoot(tempDirs, "openclaw-release-metadata-");
git(dir, ["init", "-q", "--initial-branch=main"]);