From ddbec923d4eea2e5e0b5ef4b3bb20e9ec70f7301 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:30:20 +0200 Subject: [PATCH] fix(plugin-sdk): cover the full public API baseline --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- scripts/check-changed.d.mts | 2 + scripts/check-changed.mjs | 29 ++ src/plugin-sdk/api-baseline.test.ts | 58 +++ src/plugin-sdk/api-baseline.ts | 330 +++++++++++++----- test/scripts/changed-lanes.test.ts | 65 ++++ 7 files changed, 407 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be5dbc0c38c5..ec6f1aadd05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4754db44e524..967b764d20ab 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/scripts/check-changed.d.mts b/scripts/check-changed.d.mts index 20217c5d395a..7cacd86b0b96 100644 --- a/scripts/check-changed.d.mts +++ b/scripts/check-changed.d.mts @@ -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; diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 47293f5d36e0..f25ff3637efc 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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", diff --git a/src/plugin-sdk/api-baseline.test.ts b/src/plugin-sdk/api-baseline.test.ts index e1becac25221..cc59721f4372 100644 --- a/src/plugin-sdk/api-baseline.test.ts +++ b/src/plugin-sdk/api-baseline.test.ts @@ -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;", + ); + 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 =", + ); + expect(rendered.json).not.toContain('"line":'); + expect(rendered.jsonl).not.toContain('"sourceLine":'); + }); }); diff --git a/src/plugin-sdk/api-baseline.ts b/src/plugin-sdk/api-baseline.ts index d80145881762..87f60dd896bf 100644 --- a/src/plugin-sdk/api-baseline.ts +++ b/src/plugin-sdk/api-baseline.ts @@ -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 { 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, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index d9f7f4c9e028..75eb6a7e4206 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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"]);