#!/usr/bin/env node // Reports plugin SDK export surface metadata. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import * as ts from "typescript/unstable/ast"; import { SignatureKind, SymbolFlags, type Checker, type Project, type Symbol, } from "typescript/unstable/async"; import { booleanFlag, parseFlagArgs } from "./lib/arg-utils.mts"; import { formatNativeTypeScriptDiagnostics } from "./lib/native-typescript-diagnostics.mts"; import { createNativeTypeScriptProjectAsync } from "./lib/native-typescript.mts"; import { deprecatedBarrelPluginSdkEntrypoints, deprecatedPublicPluginSdkEntrypoints, packagedPrivatePluginSdkRuntimeEntrypoints, pluginSdkEntrypoints, privateLocalOnlyPluginSdkEntrypoints, publicPluginSdkEntrypoints, } from "./lib/plugin-sdk-entries.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); type ExportEntryStats = { callableExports: number; deprecatedCallableExports: number; deprecatedExports: number; exports: number; }; function usage() { return `Usage: node --import tsx scripts/plugin-sdk-surface-report.mts [--check] Reports plugin SDK export surface metadata. Options: --check Fail when SDK surface budgets are exceeded. -h, --help Show this help. `; } function parsePluginSdkSurfaceReportArgs(argv: string[]) { return parseFlagArgs( argv, { check: false, help: false }, [ booleanFlag("--check", "check", true, { repeatable: true }), booleanFlag("--help", "help", true, { repeatable: true }), booleanFlag("-h", "help", true, { repeatable: true }), ], { ignoreDoubleDash: false, onUnhandledArg(arg: string) { throw new Error(`Unknown plugin SDK surface report option: ${arg}`); }, }, ); } const publicEntrypointSet = new Set(publicPluginSdkEntrypoints); const localOnlyEntrypointSet = new Set(privateLocalOnlyPluginSdkEntrypoints); const packagedPrivateRuntimeEntrypointSet = new Set(packagedPrivatePluginSdkRuntimeEntrypoints); const deprecatedPublicEntrypointSet = new Set(deprecatedPublicPluginSdkEntrypoints); const deprecatedBarrelEntrypointSet = new Set(deprecatedBarrelPluginSdkEntrypoints); const forbiddenPublicSubpaths = new Set(["test-utils"]); function readPluginSdkSurfaceBudgetEnv( name: string, fallback: number, env: NodeJS.ProcessEnv = process.env, ) { const raw = env[name]; if (raw === undefined) { return fallback; } const value = raw.trim(); if (!/^\d+$/u.test(value)) { throw new Error(`${name} must be a non-negative integer`); } const parsed = Number(value); if (!Number.isSafeInteger(parsed)) { throw new Error(`${name} must be a safe non-negative integer`); } return parsed; } function readPluginSdkEntrypointBudgetEnv( name: string, fallback: Readonly>, env: NodeJS.ProcessEnv = process.env, ) { const raw = env[name]; if (raw === undefined) { return fallback; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error(`${name} must be a JSON object of entrypoint integer budgets`); } if (!isRecord(parsed)) { throw new Error(`${name} must be a JSON object of entrypoint integer budgets`); } const overrides: Record = {}; for (const [entrypoint, value] of Object.entries(parsed)) { if (typeof value !== "number" || !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({ // +1 each: legacy AgentHarness remains projected through the core and plugin-entry // compatibility barrels while external harnesses migrate to AgentHarnessV2. core: 3, "plugin-entry": 1, // Shipped synchronous capture remains available while plugins migrate to async capture. "proxy-capture": 9, routing: 1, // +4: shipped default/session-agent resolvers remain available through // compatibility barrels while callers migrate to explicit/sole selection. health: 1, "agent-scope-runtime": 4, // +1: shipped channel setup state-migration declaration during its migration window. "channel-entry-contract": 1, "approval-gateway-runtime": 1, "approval-handler-runtime": 1, "approval-reply-runtime": 0, "config-contracts": 0, "inbound-reply-dispatch": 21, "interactive-runtime": 11, "ssrf-policy": 1, "ssrf-runtime": 1, // +1: deprecated agent media projection re-export during the media migration window. "media-runtime": 3, // +3: deprecated media projection type, builder, and local-roots compatibility re-export. "agent-media-payload": 3, // +2: deprecated media projection type and builder. "reply-payload": 2, "agent-runtime": 4, "memory-host-core": 2, // +4: session-write lease no-op compatibility stubs through the 2026.10 train. // +4: legacy AgentHarness, attempt, embedded-run, and side-question contracts remain // deprecated while external harnesses migrate to required-capability V2 contracts. // +1: bounded structured-input compiler/executor for native harness protocol adapters. "agent-harness": 2, "agent-harness-runtime": 10, // +4: deprecated media projection type, builder, and turn aliases. "channel-inbound": 18, // +2: Slack progress-draft render bridge (function + mode type). "channel-outbound": 2, // +2: WhatsApp ack-policy bridge (function + mode type). "channel-feedback": 2, "channel-pairing": 0, "channel-policy": 7, "channel-send-result": 1, "reply-runtime": 1, "security-runtime": 1, "session-store-runtime": 4, // +2: shipped Slack and Discord setup helpers retained through their package migration window. "setup-runtime": 2, "reply-history": 6, "provider-auth": 19, } satisfies Record); export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env) { const budgets = { publicEntrypoints: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 151, env, ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", // +1: createChannelSecretContract consolidates seven channel secret contracts (approved by Peter, 2026-10-01). // +1: createSessionHeaderLink shares plugin-owned conversation navigation (PR #158742). // +4: owner-approved replay V2 types on core and plugin-entry (2026-10-01). 3635, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", // +1: createChannelSecretContract consolidates seven channel secret contracts (approved by Peter, 2026-10-01). // +1: createSessionHeaderLink shares plugin-owned conversation navigation (PR #158742). 2109, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", 141, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", 0, env, ), }; const publicDeprecatedExportsByEntrypointBudget = readPluginSdkEntrypointBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS_BY_ENTRYPOINT", defaultPublicDeprecatedExportsByEntrypointBudget, env, ); return { budgets, publicDeprecatedExportsByEntrypointBudget }; } function entrypointPath(entrypoint: string) { return path.join(repoRoot, "src", "plugin-sdk", `${entrypoint}.ts`); } function readPackageExportedSubpaths() { const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); return Object.keys(packageJson.exports ?? {}) .filter((key) => key.startsWith("./plugin-sdk/")) .map((key) => key.slice("./plugin-sdk/".length)) .toSorted(); } function unwrapAlias(checker: Checker, symbol: Symbol) { return symbol.flags & SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; } async function hasDeprecatedTag(checker: Checker, symbol: Symbol) { return (await checker.getJsDocTagsOfSymbol(symbol)).some((tag) => tag.name === "deprecated"); } async function isCallableExport(checker: Checker, target: Symbol, sourceFile: ts.SourceFile) { const declaration = (await target.valueDeclaration?.resolve()) ?? (await target.declarations[0]?.resolve()) ?? sourceFile; const type = await checker.getTypeOfSymbolAtLocation(target, declaration); return (await checker.getSignaturesOfType(type, SignatureKind.Call)).length > 0; } function countWildcardReexports(entrypoints: string[]) { let count = 0; const matches: string[] = []; for (const entrypoint of entrypoints) { const sourcePath = entrypointPath(entrypoint); const source = fs.readFileSync(sourcePath, "utf8"); const lines = source.split(/\r?\n/u); for (const [index, line] of lines.entries()) { if (/^\s*export\s+(?:type\s+)?\*\s+from\s+["'][^"']+["']/u.test(line)) { count += 1; matches.push(`${path.relative(repoRoot, sourcePath)}:${index + 1}`); } } } return { count, matches }; } async function collectExportStats(project: Project, entrypoints: string[]) { const { program, checker } = project; const byEntrypoint = new Map(); for (const entrypoint of entrypoints) { const sourceFile = await program.getSourceFile(entrypointPath(entrypoint)); if (!sourceFile) { byEntrypoint.set(entrypoint, { exports: 0, callableExports: 0, deprecatedExports: 0, deprecatedCallableExports: 0, }); continue; } const moduleSymbol = await checker.getSymbolAtLocation(sourceFile); const symbols = moduleSymbol ? await checker.getExportsOfModule(moduleSymbol) : []; let callableExports = 0; let deprecatedExports = 0; let deprecatedCallableExports = 0; const deprecatedEntrypoint = deprecatedPublicEntrypointSet.has(entrypoint); // Let the native client batch one entrypoint's reads, and settle them all before closing it. const observations = await Promise.allSettled( symbols.map(async (symbol) => { const target = await unwrapAlias(checker, symbol); const callable = await isCallableExport(checker, target, sourceFile); const deprecated = deprecatedEntrypoint || (await hasDeprecatedTag(checker, symbol)) || (await hasDeprecatedTag(checker, target)); return { callable, deprecated }; }), ); for (const observation of observations) { if (observation.status === "rejected") { throw observation.reason; } const { callable, deprecated } = observation.value; if (callable) { callableExports += 1; } if (deprecated) { deprecatedExports += 1; if (callable) { deprecatedCallableExports += 1; } } } byEntrypoint.set(entrypoint, { exports: symbols.length, callableExports, deprecatedExports, deprecatedCallableExports, }); } return byEntrypoint; } function selectExportStats( scannedStats: Awaited>, entrypoints: string[], ) { const byEntrypoint = new Map(); const totals = { entrypoints: entrypoints.length, exports: 0, callableExports: 0, deprecatedExports: 0, deprecatedCallableExports: 0, uniqueExports: 0, uniqueCallableExports: 0, }; for (const entrypoint of entrypoints) { const stats = scannedStats.get(entrypoint) ?? { exports: 0, callableExports: 0, deprecatedExports: 0, deprecatedCallableExports: 0, }; byEntrypoint.set(entrypoint, stats); totals.exports += stats.exports; totals.callableExports += stats.callableExports; totals.deprecatedExports += stats.deprecatedExports; totals.deprecatedCallableExports += stats.deprecatedCallableExports; } // Export identities are entrypoint-qualified, so the selected totals are unique. totals.uniqueExports = totals.exports; totals.uniqueCallableExports = totals.callableExports; return { byEntrypoint, totals }; } function formatStats(label: string, stats: ReturnType["totals"]) { return [ `${label}:`, ` entrypoints: ${stats.entrypoints}`, ` exports: ${stats.exports}`, ` callable exports: ${stats.callableExports}`, ` deprecated exports: ${stats.deprecatedExports}`, ` deprecated callable exports: ${stats.deprecatedCallableExports}`, ` unique entrypoint-qualified exports: ${stats.uniqueExports}`, ].join("\n"); } function collectDeprecatedEntrypointBudgetFailures( byEntrypoint: Awaited>, entrypointBudgets: Readonly>, ) { const failures: string[] = []; for (const [entrypoint, stats] of byEntrypoint) { const budget = entrypointBudgets[entrypoint] ?? 0; if (stats.deprecatedExports > budget) { failures.push( `public deprecated exports in ${entrypoint} ${stats.deprecatedExports} > ${budget}`, ); } } return failures; } export async function collectPluginSdkSurfaceReport() { const scannedEntrypoints = [ ...new Set([ ...pluginSdkEntrypoints, ...publicPluginSdkEntrypoints, ...privateLocalOnlyPluginSdkEntrypoints, ]), ]; // All inventories share one native graph; its handles never escape this report. const configFileName = path.join(repoRoot, "tsconfig.plugin-sdk-surface-report.json"); const session = await createNativeTypeScriptProjectAsync({ cwd: repoRoot, configFileName, files: { [configFileName]: JSON.stringify({ extends: "./tsconfig.json", compilerOptions: { allowJs: false, declaration: true, emitDeclarationOnly: true, module: "ESNext", moduleResolution: "Bundler", noEmit: true, skipLibCheck: true, strict: false, target: "ES2022", types: [], }, files: scannedEntrypoints.map(entrypointPath), include: [], }), }, }); try { const diagnostics = await session.project.program.getConfigFileParsingDiagnostics(); if (diagnostics.length) { throw new Error(formatNativeTypeScriptDiagnostics(diagnostics)); } const scannedStats = await collectExportStats(session.project, scannedEntrypoints); const allStats = selectExportStats(scannedStats, pluginSdkEntrypoints); const publicStats = selectExportStats(scannedStats, publicPluginSdkEntrypoints); const localOnlyStats = selectExportStats(scannedStats, privateLocalOnlyPluginSdkEntrypoints); const publicWildcards = countWildcardReexports(publicPluginSdkEntrypoints); const leakedForbiddenExports = readPackageExportedSubpaths().filter((subpath) => forbiddenPublicSubpaths.has(subpath), ); const localOnlyStillPublic = privateLocalOnlyPluginSdkEntrypoints.filter( (entrypoint) => publicEntrypointSet.has(entrypoint) && !packagedPrivateRuntimeEntrypointSet.has(entrypoint), ); const localOnlyMissingFromInventory = [...localOnlyEntrypointSet].filter( (entrypoint) => !pluginSdkEntrypoints.includes(entrypoint), ); const deprecatedMissingFromPublic = [...deprecatedPublicEntrypointSet].filter( (entrypoint) => !publicEntrypointSet.has(entrypoint), ); const deprecatedBarrelMissingFromInventory = [...deprecatedBarrelEntrypointSet].filter( (entrypoint) => !pluginSdkEntrypoints.includes(entrypoint), ); const deprecatedBarrelWithoutReexports: string[] = []; for (const entrypoint of deprecatedBarrelEntrypointSet) { const source = await session.project.program.getSourceFile(entrypointPath(entrypoint)); // Frozen facades retain named reexports without inheriting new APIs through a wildcard. if ( !source?.statements.some( (statement) => ts.isExportDeclaration(statement) && statement.moduleSpecifier !== undefined && (!statement.exportClause || ts.isNamespaceExport(statement.exportClause) || statement.exportClause.elements.length > 0), ) ) { deprecatedBarrelWithoutReexports.push(entrypoint); } } return { allStats, deprecatedBarrelMissingFromInventory, deprecatedBarrelWithoutReexports, deprecatedMissingFromPublic, leakedForbiddenExports, localOnlyMissingFromInventory, localOnlyStats, localOnlyStillPublic, publicStats, publicWildcards, }; } finally { await session.close(); } } export function evaluatePluginSdkSurfaceReport( report: Awaited>, { budgets, publicDeprecatedExportsByEntrypointBudget, }: ReturnType, ) { const failures: string[] = []; if (publicPluginSdkEntrypoints.length > budgets.publicEntrypoints) { failures.push( `public entrypoints ${publicPluginSdkEntrypoints.length} > ${budgets.publicEntrypoints}`, ); } if (report.publicStats.totals.exports > budgets.publicExports) { failures.push(`public exports ${report.publicStats.totals.exports} > ${budgets.publicExports}`); } if (report.publicStats.totals.callableExports > budgets.publicFunctionExports) { failures.push( `public callable exports ${report.publicStats.totals.callableExports} > ${budgets.publicFunctionExports}`, ); } if (report.publicStats.totals.deprecatedExports > budgets.publicDeprecatedExports) { failures.push( `public deprecated exports ${report.publicStats.totals.deprecatedExports} > ${budgets.publicDeprecatedExports}`, ); } failures.push( ...collectDeprecatedEntrypointBudgetFailures( report.publicStats.byEntrypoint, publicDeprecatedExportsByEntrypointBudget, ), ); if (report.publicWildcards.count > budgets.publicWildcardReexports) { failures.push( `public wildcard reexports ${report.publicWildcards.count} > ${budgets.publicWildcardReexports}`, ); } if (report.leakedForbiddenExports.length > 0) { failures.push(`forbidden public subpaths: ${report.leakedForbiddenExports.join(", ")}`); } if (report.localOnlyStillPublic.length > 0) { failures.push(`local-only entrypoints still public: ${report.localOnlyStillPublic.join(", ")}`); } if (report.localOnlyMissingFromInventory.length > 0) { failures.push( `local-only entrypoints missing from inventory: ${report.localOnlyMissingFromInventory.join(", ")}`, ); } if (report.deprecatedMissingFromPublic.length > 0) { failures.push( `deprecated public entrypoints missing from package surface: ${report.deprecatedMissingFromPublic.join(", ")}`, ); } if (report.deprecatedBarrelMissingFromInventory.length > 0) { failures.push( `deprecated barrel entrypoints missing from inventory: ${report.deprecatedBarrelMissingFromInventory.join(", ")}`, ); } if (report.deprecatedBarrelWithoutReexports.length > 0) { failures.push( `deprecated barrel entrypoints without reexports: ${report.deprecatedBarrelWithoutReexports.join(", ")}`, ); } return failures; } function renderPluginSdkSurfaceReport( report: Awaited>, ) { return [ formatStats("all SDK entrypoints", report.allStats.totals), formatStats("public package SDK entrypoints", report.publicStats.totals), formatStats("local-only SDK entrypoints", report.localOnlyStats.totals), `deprecated public subpaths: ${deprecatedPublicPluginSdkEntrypoints.length}`, `deprecated barrel subpaths: ${deprecatedBarrelPluginSdkEntrypoints.length}`, `public wildcard reexports: ${report.publicWildcards.count}`, `package-exported forbidden subpaths: ${report.leakedForbiddenExports.length}`, ].join("\n"); } async function main(argv: string[] = process.argv.slice(2), env = process.env) { const cliArgs = parsePluginSdkSurfaceReportArgs(argv); if (cliArgs.help) { process.stdout.write(usage()); return 0; } const budgetConfig = readPluginSdkSurfaceBudgets(env); const report = await collectPluginSdkSurfaceReport(); process.stdout.write(`${renderPluginSdkSurfaceReport(report)}\n`); const failures = evaluatePluginSdkSurfaceReport(report, budgetConfig); if (cliArgs.check && failures.length > 0) { process.stderr.write(`plugin SDK surface budget failed:\n`); for (const failure of failures) { process.stderr.write(`- ${failure}\n`); } return 1; } return 0; } const isMain = typeof process.argv[1] === "string" && process.argv[1].length > 0 && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isMain) { try { process.exitCode = await main(); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; } }