diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 894290b6f87..415d3e8c755 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -89,10 +89,15 @@ For external plugins, compatibility work follows this order: 6. remove only after the announced migration window, usually in a major release Maintainers can audit the current migration queue with -`pnpm plugins:boundary-report`. The report groups deprecated compatibility -records by removal date, counts local code/docs references, surfaces cross-owner -reserved SDK imports, and summarizes the private memory-host SDK bridge so -compatibility cleanup stays explicit instead of relying on ad hoc searches. +`pnpm plugins:boundary-report`. Use `pnpm plugins:boundary-report:summary` for +compact counts, `--owner ` for one plugin or compatibility owner, and +`pnpm plugins:boundary-report:ci` when a CI gate should fail on due +compatibility records, cross-owner reserved SDK imports, or unused reserved SDK +subpaths without a dormant classification. The report groups deprecated +compatibility records by removal date, counts local code/docs references, +surfaces cross-owner reserved SDK imports, classifies dormant reserved SDK +subpaths, and summarizes the private memory-host SDK bridge so compatibility +cleanup stays explicit instead of relying on ad hoc searches. If a manifest field is still accepted, plugin authors can keep using it until the docs and diagnostics say otherwise. New code should prefer the documented diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index e06062d54e5..e27d47900e0 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -10,7 +10,8 @@ The plugin SDK is exposed as a set of narrow subpaths under `openclaw/plugin-sdk This page catalogs the commonly used subpaths grouped by purpose. The generated full list of 200+ subpaths lives in `scripts/lib/plugin-sdk-entrypoints.json`; reserved bundled-plugin helper subpaths appear there but are implementation -detail unless a doc page explicitly promotes them. +detail unless a doc page explicitly promotes them. Maintainers can audit active +and dormant reserved helper subpaths with `pnpm plugins:boundary-report:summary`. For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview). diff --git a/package.json b/package.json index 9957d10eff2..e37b4f675f4 100644 --- a/package.json +++ b/package.json @@ -1582,7 +1582,9 @@ "plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs", "plugin-sdk:usage": "node --import tsx scripts/analyze-plugin-sdk-usage.ts", "plugins:boundary-report": "node --import tsx scripts/plugin-boundary-report.ts", + "plugins:boundary-report:ci": "node --import tsx scripts/plugin-boundary-report.ts --summary --fail-on-cross-owner --fail-on-unclassified-unused-reserved --fail-on-eligible-compat", "plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json", + "plugins:boundary-report:summary": "node --import tsx scripts/plugin-boundary-report.ts --summary", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", "postinstall": "node scripts/postinstall-bundled-plugins.mjs", "preinstall": "node scripts/preinstall-package-manager-warning.mjs", diff --git a/scripts/plugin-boundary-report.ts b/scripts/plugin-boundary-report.ts index 34df8dc3134..72628a39f79 100644 --- a/scripts/plugin-boundary-report.ts +++ b/scripts/plugin-boundary-report.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { + dormantReservedBundledPluginSdkEntrypoints, pluginSdkEntrypoints, publicPluginOwnedSdkEntrypoints, reservedBundledPluginSdkEntrypoints, @@ -24,6 +25,16 @@ const TEXT_FILE_PATTERN = /\.(?:[cm]?[jt]sx?|json|mdx?|ya?ml)$/u; const PLUGIN_SDK_SPECIFIER_PATTERN = /\b(?:from\s*["']|import\s*\(\s*["']|require\s*\(\s*["']|vi\.(?:mock|doMock)\s*\(\s*["'])(openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*))["']/g; +type CliOptions = { + json: boolean; + summary: boolean; + owner?: string; + failOnCrossOwner: boolean; + failOnEligibleCompat: boolean; + failOnUnclassifiedUnusedReserved: boolean; + help: boolean; +}; + type CompatDebtRecord = { code: string; owner: string; @@ -57,11 +68,14 @@ type BoundaryReport = { pluginSdk: { entrypointCount: number; reservedCount: number; + dormantReservedCount: number; supportedBundledFacadeCount: number; publicPluginOwnedCount: number; reservedImports: ReservedSdkImport[]; crossOwnerReservedImports: ReservedSdkImport[]; unusedReservedSubpaths: string[]; + dormantReservedSubpaths: string[]; + unclassifiedUnusedReservedSubpaths: string[]; }; memoryHostSdk: { privatePackage: boolean; @@ -71,6 +85,38 @@ type BoundaryReport = { }; }; +type BoundaryReportSummary = { + generatedAt: string; + owner?: string; + compat: { + deprecatedCount: number; + eligibleForRemovalCount: number; + deprecatedByOwner: Record; + eligibleForRemoval: Array>; + }; + pluginSdk: { + entrypointCount: number; + reservedCount: number; + dormantReservedCount: number; + supportedBundledFacadeCount: number; + publicPluginOwnedCount: number; + reservedImportCount: number; + crossOwnerReservedImportCount: number; + unusedReservedCount: number; + dormantReservedCountInUnused: number; + unclassifiedUnusedReservedCount: number; + unclassifiedUnusedReservedSubpaths: string[]; + crossOwnerReservedImports: ReservedSdkImport[]; + }; + memoryHostSdk: { + privatePackage: boolean; + exportedSubpathCount: number; + sourceBridgeFileCount: number; + packageCoreReferenceFileCount: number; + implementation: "private-core-bridge" | "package-owned" | "mixed"; + }; +}; + function collectTextFiles(dir: string): string[] { const files: string[] = []; if (!existsSync(dir)) { @@ -106,6 +152,57 @@ function isDocsFile(file: string): boolean { return file.startsWith("docs/") || file === "README.md"; } +function parseArgs(args: readonly string[]): CliOptions { + const options: CliOptions = { + json: false, + summary: false, + failOnCrossOwner: false, + failOnEligibleCompat: false, + failOnUnclassifiedUnusedReserved: false, + help: false, + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--json") { + options.json = true; + } else if (arg === "--summary") { + options.summary = true; + } else if (arg === "--owner") { + const owner = args[index + 1]; + if (!owner || owner.startsWith("--")) { + throw new Error("--owner requires a plugin or compatibility owner id"); + } + options.owner = owner; + index += 1; + } else if (arg === "--fail-on-cross-owner") { + options.failOnCrossOwner = true; + } else if (arg === "--fail-on-eligible-compat") { + options.failOnEligibleCompat = true; + } else if (arg === "--fail-on-unclassified-unused-reserved") { + options.failOnUnclassifiedUnusedReserved = true; + } else if (arg === "--help" || arg === "-h") { + options.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return options; +} + +function renderHelp(): string { + return [ + "Usage: pnpm plugins:boundary-report [--summary] [--json] [--owner ] [fail flags]", + "", + "Options:", + " --summary Print compact counts only.", + " --json Emit JSON instead of text.", + " --owner Filter compat/imports/reserved shims by owner id.", + " --fail-on-cross-owner Exit non-zero on cross-owner reserved SDK imports.", + " --fail-on-eligible-compat Exit non-zero when deprecated compat is due for removal.", + " --fail-on-unclassified-unused-reserved Exit non-zero on unused reserved SDK shims without a dormant classification.", + ].join("\n"); +} + function collectBundledPluginIds(): string[] { return readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -258,11 +355,95 @@ function collectMemoryHostBoundary(files: readonly string[]): BoundaryReport["me }; } -function buildReport(): BoundaryReport { +function matchesOwner(owner: string | undefined, value: string | undefined): boolean { + return owner === undefined || value === owner; +} + +function countByOwner(records: readonly CompatDebtRecord[]): Record { + const counts: Record = {}; + for (const record of records) { + counts[record.owner] = (counts[record.owner] ?? 0) + 1; + } + return Object.fromEntries( + Object.entries(counts).toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + +function resolveMemoryHostImplementation( + memoryHostSdk: BoundaryReport["memoryHostSdk"], +): BoundaryReportSummary["memoryHostSdk"]["implementation"] { + if (memoryHostSdk.privatePackage && memoryHostSdk.packageCoreReferenceFiles.length > 0) { + return "private-core-bridge"; + } + if (!memoryHostSdk.privatePackage && memoryHostSdk.packageCoreReferenceFiles.length === 0) { + return "package-owned"; + } + return "mixed"; +} + +function buildSummary(report: BoundaryReport, owner?: string): BoundaryReportSummary { + const eligibleForRemoval = report.compat.records + .filter((record) => record.eligibleForRemoval) + .map((record) => ({ + code: record.code, + owner: record.owner, + removeAfter: record.removeAfter, + })); + return { + generatedAt: report.generatedAt, + owner, + compat: { + deprecatedCount: report.compat.deprecatedCount, + eligibleForRemovalCount: report.compat.eligibleForRemovalCount, + deprecatedByOwner: countByOwner(report.compat.records), + eligibleForRemoval, + }, + pluginSdk: { + entrypointCount: report.pluginSdk.entrypointCount, + reservedCount: report.pluginSdk.reservedCount, + dormantReservedCount: report.pluginSdk.dormantReservedCount, + supportedBundledFacadeCount: report.pluginSdk.supportedBundledFacadeCount, + publicPluginOwnedCount: report.pluginSdk.publicPluginOwnedCount, + reservedImportCount: report.pluginSdk.reservedImports.length, + crossOwnerReservedImportCount: report.pluginSdk.crossOwnerReservedImports.length, + unusedReservedCount: report.pluginSdk.unusedReservedSubpaths.length, + dormantReservedCountInUnused: report.pluginSdk.dormantReservedSubpaths.length, + unclassifiedUnusedReservedCount: report.pluginSdk.unclassifiedUnusedReservedSubpaths.length, + unclassifiedUnusedReservedSubpaths: report.pluginSdk.unclassifiedUnusedReservedSubpaths, + crossOwnerReservedImports: report.pluginSdk.crossOwnerReservedImports, + }, + memoryHostSdk: { + privatePackage: report.memoryHostSdk.privatePackage, + exportedSubpathCount: report.memoryHostSdk.exportedSubpaths.length, + sourceBridgeFileCount: report.memoryHostSdk.sourceBridgeFiles.length, + packageCoreReferenceFileCount: report.memoryHostSdk.packageCoreReferenceFiles.length, + implementation: resolveMemoryHostImplementation(report.memoryHostSdk), + }, + }; +} + +function buildReport(options: Pick = {}): BoundaryReport { const files = collectWorkspaceTextFiles(); - const compatRecords = collectCompatDebt(files); - const reservedImports = collectReservedSdkImports(files); + const pluginIds = collectBundledPluginIds(); + const compatRecords = collectCompatDebt(files).filter((record) => + matchesOwner(options.owner, record.owner), + ); + const reservedImports = collectReservedSdkImports(files).filter( + (entry) => + matchesOwner(options.owner, entry.owner) || matchesOwner(options.owner, entry.consumerOwner), + ); const usedReserved = new Set(reservedImports.map((entry) => entry.subpath)); + const dormantReserved = new Set(dormantReservedBundledPluginSdkEntrypoints); + const unusedReservedSubpaths = reservedBundledPluginSdkEntrypoints + .filter( + (subpath) => + !usedReserved.has(subpath) && + matchesOwner(options.owner, resolvePluginOwner(subpath, pluginIds)), + ) + .toSorted(); + const dormantReservedSubpaths = unusedReservedSubpaths + .filter((subpath) => dormantReserved.has(subpath)) + .toSorted(); return { generatedAt: new Date().toISOString(), compat: { @@ -273,23 +454,54 @@ function buildReport(): BoundaryReport { pluginSdk: { entrypointCount: pluginSdkEntrypoints.length, reservedCount: reservedBundledPluginSdkEntrypoints.length, + dormantReservedCount: dormantReservedBundledPluginSdkEntrypoints.length, supportedBundledFacadeCount: supportedBundledFacadeSdkEntrypoints.length, publicPluginOwnedCount: publicPluginOwnedSdkEntrypoints.length, reservedImports, crossOwnerReservedImports: reservedImports.filter( (entry) => entry.relation === "cross-owner", ), - unusedReservedSubpaths: reservedBundledPluginSdkEntrypoints - .filter((subpath) => !usedReserved.has(subpath)) + unusedReservedSubpaths, + dormantReservedSubpaths, + unclassifiedUnusedReservedSubpaths: unusedReservedSubpaths + .filter((subpath) => !dormantReserved.has(subpath)) .toSorted(), }, memoryHostSdk: collectMemoryHostBoundary(files), }; } -function renderText(report: BoundaryReport): string { +function renderSummaryText(summary: BoundaryReportSummary): string { const lines: string[] = []; - lines.push("Plugin Boundary Report"); + lines.push(`Plugin Boundary Report${summary.owner ? ` (${summary.owner})` : ""}`); + lines.push(""); + lines.push( + `compat deprecated=${summary.compat.deprecatedCount} eligibleForRemoval=${summary.compat.eligibleForRemovalCount}`, + ); + lines.push( + `plugin-sdk entrypoints=${summary.pluginSdk.entrypointCount} reserved=${summary.pluginSdk.reservedCount} dormantReserved=${summary.pluginSdk.dormantReservedCount}`, + ); + lines.push( + ` reservedImports=${summary.pluginSdk.reservedImportCount} crossOwnerReservedImports=${summary.pluginSdk.crossOwnerReservedImportCount} unusedReserved=${summary.pluginSdk.unusedReservedCount}`, + ); + lines.push( + ` dormantUnused=${summary.pluginSdk.dormantReservedCountInUnused} unclassifiedUnused=${summary.pluginSdk.unclassifiedUnusedReservedCount}`, + ); + for (const subpath of summary.pluginSdk.unclassifiedUnusedReservedSubpaths) { + lines.push(` unclassified-unused ${subpath}`); + } + for (const entry of summary.pluginSdk.crossOwnerReservedImports) { + lines.push(` cross-owner ${entry.file}: ${entry.specifier} owner=${entry.owner ?? "unknown"}`); + } + lines.push( + `memory-host-sdk implementation=${summary.memoryHostSdk.implementation} private=${summary.memoryHostSdk.privatePackage} exports=${summary.memoryHostSdk.exportedSubpathCount} sourceBridgeFiles=${summary.memoryHostSdk.sourceBridgeFileCount} coreReferenceFiles=${summary.memoryHostSdk.packageCoreReferenceFileCount}`, + ); + return lines.join("\n"); +} + +function renderText(report: BoundaryReport, owner?: string): string { + const lines: string[] = []; + lines.push(`Plugin Boundary Report${owner ? ` (${owner})` : ""}`); lines.push(""); lines.push( `compat deprecated=${report.compat.deprecatedCount} eligibleForRemoval=${report.compat.eligibleForRemovalCount}`, @@ -301,24 +513,79 @@ function renderText(report: BoundaryReport): string { } lines.push(""); lines.push( - `plugin-sdk entrypoints=${report.pluginSdk.entrypointCount} reserved=${report.pluginSdk.reservedCount} supportedBundledFacade=${report.pluginSdk.supportedBundledFacadeCount} publicPluginOwned=${report.pluginSdk.publicPluginOwnedCount}`, + `plugin-sdk entrypoints=${report.pluginSdk.entrypointCount} reserved=${report.pluginSdk.reservedCount} dormantReserved=${report.pluginSdk.dormantReservedCount} supportedBundledFacade=${report.pluginSdk.supportedBundledFacadeCount} publicPluginOwned=${report.pluginSdk.publicPluginOwnedCount}`, ); lines.push( ` reservedImports=${report.pluginSdk.reservedImports.length} crossOwnerReservedImports=${report.pluginSdk.crossOwnerReservedImports.length} unusedReserved=${report.pluginSdk.unusedReservedSubpaths.length}`, ); + lines.push( + ` dormantUnused=${report.pluginSdk.dormantReservedSubpaths.length} unclassifiedUnused=${report.pluginSdk.unclassifiedUnusedReservedSubpaths.length}`, + ); + for (const subpath of report.pluginSdk.unclassifiedUnusedReservedSubpaths) { + lines.push(` unclassified-unused ${subpath}`); + } for (const entry of report.pluginSdk.crossOwnerReservedImports) { lines.push(` cross-owner ${entry.file}: ${entry.specifier} owner=${entry.owner ?? "unknown"}`); } lines.push(""); lines.push( - `memory-host-sdk private=${report.memoryHostSdk.privatePackage} exports=${report.memoryHostSdk.exportedSubpaths.length} sourceBridgeFiles=${report.memoryHostSdk.sourceBridgeFiles.length} coreReferenceFiles=${report.memoryHostSdk.packageCoreReferenceFiles.length}`, + `memory-host-sdk implementation=${resolveMemoryHostImplementation(report.memoryHostSdk)} private=${report.memoryHostSdk.privatePackage} exports=${report.memoryHostSdk.exportedSubpaths.length} sourceBridgeFiles=${report.memoryHostSdk.sourceBridgeFiles.length} coreReferenceFiles=${report.memoryHostSdk.packageCoreReferenceFiles.length}`, ); return lines.join("\n"); } -const report = buildReport(); -if (process.argv.includes("--json")) { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); -} else { - process.stdout.write(`${renderText(report)}\n`); +function collectFailures(report: BoundaryReport, options: CliOptions): string[] { + const failures: string[] = []; + if (options.failOnCrossOwner && report.pluginSdk.crossOwnerReservedImports.length > 0) { + failures.push( + `${report.pluginSdk.crossOwnerReservedImports.length} cross-owner reserved SDK import(s) found`, + ); + } + if ( + options.failOnUnclassifiedUnusedReserved && + report.pluginSdk.unclassifiedUnusedReservedSubpaths.length > 0 + ) { + failures.push( + `${report.pluginSdk.unclassifiedUnusedReservedSubpaths.length} unused reserved SDK subpath(s) lack dormant classification`, + ); + } + if (options.failOnEligibleCompat && report.compat.eligibleForRemovalCount > 0) { + failures.push( + `${report.compat.eligibleForRemovalCount} compatibility record(s) are due for removal`, + ); + } + return failures; +} + +let options: CliOptions; +try { + options = parseArgs(process.argv.slice(2)); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n\n${renderHelp()}\n`); + process.exitCode = 2; + process.exit(); +} + +if (options.help) { + process.stdout.write(`${renderHelp()}\n`); + process.exit(); +} + +const report = buildReport(options); +const summary = buildSummary(report, options.owner); +if (options.json) { + process.stdout.write(`${JSON.stringify(options.summary ? summary : report, null, 2)}\n`); +} else if (options.summary) { + process.stdout.write(`${renderSummaryText(summary)}\n`); +} else { + process.stdout.write(`${renderText(report, options.owner)}\n`); +} + +const failures = collectFailures(report, options); +if (failures.length > 0) { + process.stderr.write( + `${failures.map((failure) => `plugin-boundary-report: ${failure}`).join("\n")}\n`, + ); + process.exitCode = 1; } diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts index d3079fc955a..15fcb7f8864 100644 --- a/src/plugin-sdk/entrypoints.ts +++ b/src/plugin-sdk/entrypoints.ts @@ -60,6 +60,54 @@ export const reservedBundledPluginSdkEntrypoints = [ "zalouser", ] as const; +// Reserved compatibility/helper subpaths with no current tracked imports. +// Keeping them classified avoids treating dormant compatibility as unknown debt. +export const dormantReservedBundledPluginSdkEntrypoints = [ + "bluebubbles", + "bluebubbles-policy", + "browser-cdp", + "browser-control-auth", + "browser-profiles", + "browser-support", + "diagnostics-otel", + "diagnostics-prometheus", + "diffs", + "feishu", + "feishu-conversation", + "feishu-setup", + "github-copilot-login", + "googlechat", + "googlechat-runtime-shared", + "irc", + "irc-surface", + "line", + "line-core", + "line-runtime", + "line-surface", + "llm-task", + "matrix", + "matrix-helper", + "matrix-runtime-heavy", + "matrix-runtime-surface", + "matrix-surface", + "matrix-thread-bindings", + "mattermost", + "mattermost-policy", + "memory-lancedb", + "msteams", + "nextcloud-talk", + "nostr", + "opencode", + "telegram-command-ui", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalo-setup", + "zalouser", +] as const; + // Supported SDK facades backed by bundled plugins. These are intentionally public // until they move to generic, plugin-neutral contracts. export const supportedBundledFacadeSdkEntrypoints = [ diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index d929cf62cf5..39d698b6795 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + dormantReservedBundledPluginSdkEntrypoints, pluginSdkEntrypoints, publicPluginOwnedSdkEntrypoints, reservedBundledPluginSdkEntrypoints, @@ -487,6 +488,32 @@ function collectCrossOwnerReservedSdkImports(): Array<{ return leaks; } +function collectReservedSdkSubpathImports(): string[] { + const imports = new Set(); + const reserved = new Set(reservedBundledPluginSdkEntrypoints); + const importPatterns = [ + /\b(?:import|export)\b[\s\S]*?\bfrom\s*["']openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)["']/g, + /\bimport\s*\(\s*["']openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)["']\s*\)/g, + /\bvi\.(?:mock|doMock)\s*\(\s*["']openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)["']/g, + ]; + + for (const root of ["src", "test", "extensions", "packages", "scripts"]) { + for (const file of collectCodeFiles(resolve(REPO_ROOT, root))) { + const source = readFileSync(file, "utf8"); + for (const importPattern of importPatterns) { + for (const match of source.matchAll(importPattern)) { + const subpath = match[1]; + if (subpath && reserved.has(subpath)) { + imports.add(subpath); + } + } + } + } + } + + return [...imports].toSorted(); +} + describe("plugin-sdk package contract guardrails", () => { it("keeps plugin-sdk entrypoint metadata unique", () => { const counts = new Map(); @@ -508,8 +535,12 @@ describe("plugin-sdk package contract guardrails", () => { it("keeps bundled plugin SDK compatibility subpaths explicitly classified", () => { const entrypoints = new Set(pluginSdkEntrypoints); const reserved = new Set(reservedBundledPluginSdkEntrypoints); + const dormantReserved = new Set(dormantReservedBundledPluginSdkEntrypoints); const supported = new Set(supportedBundledFacadeSdkEntrypoints); const unknownReserved = [...reserved].filter((entrypoint) => !entrypoints.has(entrypoint)); + const unknownDormantReserved = [...dormantReserved].filter( + (entrypoint) => !reserved.has(entrypoint), + ); const unknownSupported = [...supported].filter((entrypoint) => !entrypoints.has(entrypoint)); const unclassifiedBundledFacades = collectBundledFacadeSdkEntrypoints().filter( (entrypoint) => !reserved.has(entrypoint) && !supported.has(entrypoint), @@ -520,11 +551,13 @@ describe("plugin-sdk package contract guardrails", () => { expect({ unknownReserved, + unknownDormantReserved, unknownSupported, unclassifiedBundledFacades, unreservedPrivateSurfaces, }).toEqual({ unknownReserved: [], + unknownDormantReserved: [], unknownSupported: [], unclassifiedBundledFacades: [], unreservedPrivateSurfaces: [], @@ -637,6 +670,22 @@ describe("plugin-sdk package contract guardrails", () => { expect(collectCrossOwnerReservedSdkImports()).toEqual([]); }); + it("keeps unused reserved SDK compatibility subpaths classified as dormant", () => { + const usedReserved = new Set(collectReservedSdkSubpathImports()); + const dormantReserved = new Set(dormantReservedBundledPluginSdkEntrypoints); + const usedButDormant = [...usedReserved].filter((entrypoint) => + dormantReserved.has(entrypoint), + ); + const unusedUnclassified = reservedBundledPluginSdkEntrypoints.filter( + (entrypoint) => !usedReserved.has(entrypoint) && !dormantReserved.has(entrypoint), + ); + + expect({ usedButDormant, unusedUnclassified }).toEqual({ + usedButDormant: [], + unusedUnclassified: [], + }); + }); + it("keeps generic core poll helpers free of plugin owner names", () => { expect(collectGenericCoreOwnerNameLeaks()).toEqual([]); }); diff --git a/test/scripts/plugin-boundary-report.test.ts b/test/scripts/plugin-boundary-report.test.ts new file mode 100644 index 00000000000..69bdc930227 --- /dev/null +++ b/test/scripts/plugin-boundary-report.test.ts @@ -0,0 +1,41 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = resolve(import.meta.dirname, "../.."); + +function runBoundaryReport(...args: string[]): string { + return execFileSync( + process.execPath, + ["--import", "tsx", "scripts/plugin-boundary-report.ts", ...args], + { + cwd: REPO_ROOT, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }, + ); +} + +describe("plugin-boundary-report", () => { + it("emits compact CI-safe summary JSON", () => { + const output = runBoundaryReport( + "--summary", + "--json", + "--fail-on-cross-owner", + "--fail-on-unclassified-unused-reserved", + ); + const summary = JSON.parse(output) as { + pluginSdk?: { + crossOwnerReservedImportCount?: unknown; + unclassifiedUnusedReservedCount?: unknown; + }; + memoryHostSdk?: { + implementation?: unknown; + }; + }; + + expect(summary.pluginSdk?.crossOwnerReservedImportCount).toBe(0); + expect(summary.pluginSdk?.unclassifiedUnusedReservedCount).toBe(0); + expect(summary.memoryHostSdk?.implementation).toBe("private-core-bridge"); + }); +});