From d5c9e7ea991b9facdf762bb3b23bfaff84cb8eeb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 16 Jun 2026 02:33:28 +0200 Subject: [PATCH] test(plugin-sdk): ratchet surface budget checks --- .github/workflows/workflow-sanity.yml | 3 + scripts/plugin-sdk-surface-report.mjs | 146 +++++++++++++++++- scripts/release-preflight.mjs | 1 + src/plugin-sdk/channel-outbound.ts | 8 +- test/scripts/ci-workflow-guards.test.ts | 15 ++ .../scripts/plugin-sdk-surface-report.test.ts | 18 +++ 6 files changed, 186 insertions(+), 5 deletions(-) diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 6199ede563c..5fdfa58d12c 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -251,3 +251,6 @@ jobs: - name: Check plugin SDK API baseline drift run: pnpm plugin-sdk:api:check + + - name: Check plugin SDK surface budget + run: pnpm plugin-sdk:surface:check diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 17e724766ba..457c4175ef3 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -37,21 +37,145 @@ function readBudgetEnv(name, fallback) { return parsed; } +function readEntrypointBudgetEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) { + return fallback; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`${name} must be a JSON object of entrypoint integer budgets`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${name} must be a JSON object of entrypoint integer budgets`); + } + + const overrides = {}; + for (const [entrypoint, value] of Object.entries(parsed)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name}.${entrypoint} must be a safe non-negative integer`); + } + overrides[entrypoint] = value; + } + return Object.freeze({ ...fallback, ...overrides }); +} + +const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ + core: 2, + lmstudio: 1, + "provider-setup": 1, + "self-hosted-provider-setup": 14, + routing: 1, + runtime: 3, + "runtime-logger": 3, + "runtime-secret-resolution": 5, + "setup-adapter-runtime": 1, + "channel-streaming": 47, + "approval-reply-runtime": 1, + "config-runtime": 123, + "config-contracts": 1, + "config-types": 415, + "config-schema": 3, + "reply-dedupe": 1, + "inbound-reply-dispatch": 33, + "channel-reply-pipeline": 12, + "channel-reply-options-runtime": 2, + "channel-runtime": 144, + "interactive-runtime": 13, + "outbound-send-deps": 4, + "outbound-runtime": 16, + "file-access-runtime": 2, + "infra-runtime": 570, + "ssrf-policy": 1, + "ssrf-runtime": 1, + "media-runtime": 2, + "text-runtime": 188, + "agent-runtime": 7, + "plugin-runtime": 13, + "channel-secret-runtime": 23, + "secret-file-runtime": 1, + "security-runtime": 7, + "agent-harness": 7, + "agent-harness-runtime": 7, + types: 6, + "agent-config-primitives": 2, + "command-auth": 81, + compat: 152, + "direct-dm": 9, + "direct-dm-access": 5, + discord: 48, + mattermost: 7, + matrix: 1, + "channel-config-schema-legacy": 22, + "channel-actions": 2, + "channel-envelope": 3, + "channel-inbound": 21, + "channel-inbound-roots": 1, + "channel-logging": 4, + "channel-location": 4, + "channel-mention-gating": 7, + "channel-lifecycle": 23, + "channel-ingress": 8, + "channel-message": 228, + "channel-message-runtime": 225, + "channel-pairing-paths": 1, + "channel-policy": 8, + "channel-route": 5, + "session-store-runtime": 1, + "group-access": 13, + "media-generation-runtime-shared": 3, + "music-generation-core": 20, + "reply-history": 8, + "messaging-targets": 12, + "memory-core": 45, + "memory-core-engine-runtime": 15, + "memory-core-host-multimodal": 3, + "memory-core-host-query": 2, + "memory-core-host-events": 11, + "memory-core-host-status": 1, + "memory-core-host-runtime-core": 1, + "memory-host-core": 1, + "memory-host-files": 7, + "memory-host-status": 72, + "provider-auth": 20, + "provider-oauth-runtime": 2, + "provider-auth-login": 3, + "provider-model-shared": 29, + "provider-stream-family": 40, + "provider-stream-shared": 28, + "provider-stream": 40, + "provider-web-search": 1, + "provider-zai-endpoint": 3, + "telegram-account": 3, + "telegram-command-config": 7, + "webhook-ingress": 2, + "webhook-path": 2, + zalouser: 5, + zod: 282, +}); + let budgets; +let publicDeprecatedExportsByEntrypointBudget; try { budgets = { - publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 308), - publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 9920), - publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5031), + publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 319), + publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10269), + publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5159), publicDeprecatedExports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", - 3143, + 3230, ), publicWildcardReexports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", 215, ), }; + publicDeprecatedExportsByEntrypointBudget = readEntrypointBudgetEnv( + "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT", + defaultPublicDeprecatedExportsByEntrypointBudget, + ); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); @@ -194,6 +318,19 @@ function formatStats(label, stats) { ].join("\n"); } +function collectDeprecatedEntrypointBudgetFailures(byEntrypoint) { + const failures = []; + for (const [entrypoint, stats] of byEntrypoint) { + const budget = publicDeprecatedExportsByEntrypointBudget[entrypoint] ?? 0; + if (stats.deprecatedExports > budget) { + failures.push( + `public deprecated exports in ${entrypoint} ${stats.deprecatedExports} > ${budget}`, + ); + } + } + return failures; +} + const allStats = collectExportStats(pluginSdkEntrypoints); const publicStats = collectExportStats(publicPluginSdkEntrypoints); const localOnlyStats = collectExportStats(privateLocalOnlyPluginSdkEntrypoints); @@ -246,6 +383,7 @@ if (publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) { `public deprecated exports ${publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`, ); } +failures.push(...collectDeprecatedEntrypointBudgetFailures(publicStats.byEntrypoint)); if (publicWildcards.count > budgets.publicWildcardReexports) { failures.push( `public wildcard reexports ${publicWildcards.count} > ${budgets.publicWildcardReexports}`, diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index d1dbf86e44b..8ffed92cd0a 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -29,6 +29,7 @@ const checkCommands = [ { name: "config docs baseline", args: ["config:docs:check"] }, { name: "plugin SDK exports", args: ["plugin-sdk:check-exports"] }, { name: "plugin SDK API baseline", args: ["plugin-sdk:api:check"] }, + { name: "plugin SDK surface budget", args: ["plugin-sdk:surface:check"] }, ]; if (fix) { diff --git a/src/plugin-sdk/channel-outbound.ts b/src/plugin-sdk/channel-outbound.ts index 6691cf83081..e5cbf377333 100644 --- a/src/plugin-sdk/channel-outbound.ts +++ b/src/plugin-sdk/channel-outbound.ts @@ -78,7 +78,13 @@ export type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; export { sanitizeForPlainText } from "../infra/outbound/sanitize-text.js"; export { logAckFailure, logTypingFailure } from "../channels/logging.js"; export * from "../channels/streaming.js"; -export * from "../channels/progress-draft-compositor.js"; +export { + createChannelProgressDraftCompositor, + type ChannelProgressDraftCompositor, + type ChannelProgressDraftCompositorLine, + type ChannelProgressDraftMode, + type ChannelProgressDraftUpdateOptions, +} from "../channels/progress-draft-compositor.js"; export { classifyDurableSendRecoveryState, createChannelMessageAdapterFromOutbound, diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 6911dcc8eb3..e4d4604da1e 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -131,6 +131,21 @@ describe("ci workflow guards", () => { } }); + it("runs plugin SDK API and surface drift checks in workflow sanity", () => { + const workflow = readWorkflowSanityWorkflow(); + const steps = workflow.jobs["generated-doc-baselines"].steps; + const stepNames = steps.map((step) => step.name); + + expect(stepNames).toContain("Check plugin SDK API baseline drift"); + expect(stepNames).toContain("Check plugin SDK surface budget"); + expect(stepNames.indexOf("Check plugin SDK API baseline drift")).toBeLessThan( + stepNames.indexOf("Check plugin SDK surface budget"), + ); + expect(steps.find((step) => step.name === "Check plugin SDK surface budget").run).toBe( + "pnpm plugin-sdk:surface:check", + ); + }); + it("bounds platform checkout fetches without GNU timeout", () => { const workflow = readCiWorkflow(); diff --git a/test/scripts/plugin-sdk-surface-report.test.ts b/test/scripts/plugin-sdk-surface-report.test.ts index e91c0105b1b..50b4e99e342 100644 --- a/test/scripts/plugin-sdk-surface-report.test.ts +++ b/test/scripts/plugin-sdk-surface-report.test.ts @@ -39,4 +39,22 @@ describe("plugin SDK surface report", () => { ); expect(result.stderr).not.toContain("at "); }); + + it("accepts exact deprecated export budget overrides by public entrypoint", () => { + const result = runSurfaceReport({ + OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT: JSON.stringify({ core: 2 }), + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("rejects deprecated export growth by public entrypoint", () => { + const result = runSurfaceReport({ + OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT: JSON.stringify({ core: 1 }), + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("public deprecated exports in core 2 > 1"); + }); });