From 00e30ba8d90f5398f11688bef3e317ef15b0cf90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 04:12:27 +0100 Subject: [PATCH] chore: add plugin boundary report --- docs/plugins/sdk-migration.md | 6 + package.json | 2 + scripts/plugin-boundary-report.ts | 324 ++++++++++++++++++ ...tension-package-project-boundaries.test.ts | 41 +++ ...in-sdk-package-contract-guardrails.test.ts | 15 + 5 files changed, 388 insertions(+) create mode 100644 scripts/plugin-boundary-report.ts diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index de64c2dd8e2..894290b6f87 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -88,6 +88,12 @@ For external plugins, compatibility work follows this order: 5. document the deprecation and migration path 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. + 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 replacement, but existing plugins should not break during ordinary minor diff --git a/package.json b/package.json index cbc132d8a79..9957d10eff2 100644 --- a/package.json +++ b/package.json @@ -1581,6 +1581,8 @@ "plugin-sdk:check-exports": "node scripts/sync-plugin-sdk-exports.mjs --check", "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:json": "node --import tsx scripts/plugin-boundary-report.ts --json", "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 new file mode 100644 index 00000000000..34df8dc3134 --- /dev/null +++ b/scripts/plugin-boundary-report.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { + pluginSdkEntrypoints, + publicPluginOwnedSdkEntrypoints, + reservedBundledPluginSdkEntrypoints, + supportedBundledFacadeSdkEntrypoints, +} from "../src/plugin-sdk/entrypoints.ts"; +import { PLUGIN_COMPAT_RECORDS } from "../src/plugins/compat/registry.ts"; +import type { PluginCompatRecord } from "../src/plugins/compat/types.ts"; + +const REPO_ROOT = process.cwd(); +const SOURCE_ROOTS = ["src", "extensions", "packages", "scripts", "test", "docs"] as const; +const SKIPPED_DIRS = new Set([ + ".artifacts", + ".git", + "coverage", + "dist", + "dist-runtime", + "node_modules", +]); +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 CompatDebtRecord = { + code: string; + owner: string; + status: PluginCompatRecord["status"]; + removeAfter?: string; + replacement: string; + docsPath: string; + surfaces: readonly string[]; + tokens: string[]; + codeReferenceFiles: string[]; + docReferenceFiles: string[]; + eligibleForRemoval: boolean; +}; + +type ReservedSdkImport = { + file: string; + specifier: string; + subpath: string; + owner?: string; + consumerOwner?: string; + relation: "owner" | "cross-owner" | "workspace"; +}; + +type BoundaryReport = { + generatedAt: string; + compat: { + deprecatedCount: number; + eligibleForRemovalCount: number; + records: CompatDebtRecord[]; + }; + pluginSdk: { + entrypointCount: number; + reservedCount: number; + supportedBundledFacadeCount: number; + publicPluginOwnedCount: number; + reservedImports: ReservedSdkImport[]; + crossOwnerReservedImports: ReservedSdkImport[]; + unusedReservedSubpaths: string[]; + }; + memoryHostSdk: { + privatePackage: boolean; + exportedSubpaths: string[]; + sourceBridgeFiles: string[]; + packageCoreReferenceFiles: string[]; + }; +}; + +function collectTextFiles(dir: string): string[] { + const files: string[] = []; + if (!existsSync(dir)) { + return files; + } + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIPPED_DIRS.has(entry.name)) { + continue; + } + const nextPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectTextFiles(nextPath)); + continue; + } + if (entry.isFile() && TEXT_FILE_PATTERN.test(entry.name)) { + files.push(nextPath); + } + } + return files; +} + +function collectWorkspaceTextFiles(): string[] { + return SOURCE_ROOTS.flatMap((root) => collectTextFiles(resolve(REPO_ROOT, root))).toSorted( + (left, right) => relative(REPO_ROOT, left).localeCompare(relative(REPO_ROOT, right)), + ); +} + +function repoRelative(file: string): string { + return relative(REPO_ROOT, file).replaceAll("\\", "/"); +} + +function isDocsFile(file: string): boolean { + return file.startsWith("docs/") || file === "README.md"; +} + +function collectBundledPluginIds(): string[] { + return readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted((left, right) => right.length - left.length || left.localeCompare(right)); +} + +function resolvePluginOwner(entrypoint: string, pluginIds: readonly string[]): string | undefined { + return pluginIds.find( + (pluginId) => entrypoint === pluginId || entrypoint.startsWith(`${pluginId}-`), + ); +} + +function resolveConsumerOwner(file: string): string | undefined { + return /^extensions\/([^/]+)\//u.exec(file)?.[1]; +} + +function extractCompatTokens(record: PluginCompatRecord): string[] { + const tokens = new Set(); + const values = [record.code, record.replacement, ...record.surfaces, ...record.diagnostics]; + for (const value of values) { + for (const match of value.matchAll(/`([^`]+)`/g)) { + const token = match[1]?.trim(); + if (token && !token.includes(" ")) { + tokens.add(token); + } + } + for (const match of value.matchAll(/\bopenclaw\/[a-z0-9/-]+\b/g)) { + tokens.add(match[0]); + } + for (const match of value.matchAll(/\bOPENCLAW_[A-Z0-9_]+\b/g)) { + tokens.add(match[0]); + } + for (const match of value.matchAll(/\b[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)+\b/g)) { + tokens.add(match[0]); + } + for (const match of value.matchAll(/\b[a-z][a-zA-Z0-9_]*_[a-zA-Z0-9_]+\b/g)) { + tokens.add(match[0]); + } + } + return [...tokens].toSorted(); +} + +function collectReferenceFiles(files: readonly string[], tokens: readonly string[]) { + const codeReferenceFiles = new Set(); + const docReferenceFiles = new Set(); + for (const file of files) { + const relativeFile = repoRelative(file); + if (relativeFile === "src/plugins/compat/registry.ts") { + continue; + } + const source = readFileSync(file, "utf8"); + if (!tokens.some((token) => source.includes(token))) { + continue; + } + if (isDocsFile(relativeFile)) { + docReferenceFiles.add(relativeFile); + } else { + codeReferenceFiles.add(relativeFile); + } + } + return { + codeReferenceFiles: [...codeReferenceFiles].toSorted(), + docReferenceFiles: [...docReferenceFiles].toSorted(), + }; +} + +function collectCompatDebt(files: readonly string[], today = new Date()): CompatDebtRecord[] { + return PLUGIN_COMPAT_RECORDS.filter((record) => record.status === "deprecated") + .map((record) => { + const tokens = extractCompatTokens(record); + const references = collectReferenceFiles(files, tokens); + const eligibleForRemoval = record.removeAfter + ? new Date(`${record.removeAfter}T00:00:00Z`) <= today + : false; + return { + code: record.code, + owner: record.owner, + status: record.status, + removeAfter: record.removeAfter, + replacement: record.replacement, + docsPath: record.docsPath, + surfaces: record.surfaces, + tokens, + codeReferenceFiles: references.codeReferenceFiles, + docReferenceFiles: references.docReferenceFiles, + eligibleForRemoval, + }; + }) + .toSorted( + (left, right) => + (left.removeAfter ?? "").localeCompare(right.removeAfter ?? "") || + left.owner.localeCompare(right.owner) || + left.code.localeCompare(right.code), + ); +} + +function collectReservedSdkImports(files: readonly string[]): ReservedSdkImport[] { + const reserved = new Set(reservedBundledPluginSdkEntrypoints); + const pluginIds = collectBundledPluginIds(); + const imports: ReservedSdkImport[] = []; + for (const file of files) { + const relativeFile = repoRelative(file); + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(PLUGIN_SDK_SPECIFIER_PATTERN)) { + const specifier = match[1]; + const subpath = match[2]; + if (!specifier || !subpath || !reserved.has(subpath)) { + continue; + } + const owner = resolvePluginOwner(subpath, pluginIds); + const consumerOwner = resolveConsumerOwner(relativeFile); + const relation = + owner && consumerOwner ? (owner === consumerOwner ? "owner" : "cross-owner") : "workspace"; + imports.push({ file: relativeFile, specifier, subpath, owner, consumerOwner, relation }); + } + } + return imports.toSorted( + (left, right) => + left.subpath.localeCompare(right.subpath) || + left.file.localeCompare(right.file) || + left.specifier.localeCompare(right.specifier), + ); +} + +function collectMemoryHostBoundary(files: readonly string[]): BoundaryReport["memoryHostSdk"] { + const packageJson = JSON.parse( + readFileSync(resolve(REPO_ROOT, "packages/memory-host-sdk/package.json"), "utf8"), + ) as { private?: boolean; exports?: Record }; + const sourceBridgeFiles: string[] = []; + const packageCoreReferenceFiles = new Set(); + for (const file of files) { + const relativeFile = repoRelative(file); + if (!relativeFile.startsWith("packages/memory-host-sdk/src/")) { + continue; + } + const source = readFileSync(file, "utf8"); + if (source.includes("src/memory-host-sdk/")) { + sourceBridgeFiles.push(relativeFile); + } + if (source.includes("../../../../src/") || source.includes("../../../src/")) { + packageCoreReferenceFiles.add(relativeFile); + } + } + return { + privatePackage: packageJson.private === true, + exportedSubpaths: Object.keys(packageJson.exports ?? {}).toSorted(), + sourceBridgeFiles: sourceBridgeFiles.toSorted(), + packageCoreReferenceFiles: [...packageCoreReferenceFiles].toSorted(), + }; +} + +function buildReport(): BoundaryReport { + const files = collectWorkspaceTextFiles(); + const compatRecords = collectCompatDebt(files); + const reservedImports = collectReservedSdkImports(files); + const usedReserved = new Set(reservedImports.map((entry) => entry.subpath)); + return { + generatedAt: new Date().toISOString(), + compat: { + deprecatedCount: compatRecords.length, + eligibleForRemovalCount: compatRecords.filter((record) => record.eligibleForRemoval).length, + records: compatRecords, + }, + pluginSdk: { + entrypointCount: pluginSdkEntrypoints.length, + reservedCount: reservedBundledPluginSdkEntrypoints.length, + supportedBundledFacadeCount: supportedBundledFacadeSdkEntrypoints.length, + publicPluginOwnedCount: publicPluginOwnedSdkEntrypoints.length, + reservedImports, + crossOwnerReservedImports: reservedImports.filter( + (entry) => entry.relation === "cross-owner", + ), + unusedReservedSubpaths: reservedBundledPluginSdkEntrypoints + .filter((subpath) => !usedReserved.has(subpath)) + .toSorted(), + }, + memoryHostSdk: collectMemoryHostBoundary(files), + }; +} + +function renderText(report: BoundaryReport): string { + const lines: string[] = []; + lines.push("Plugin Boundary Report"); + lines.push(""); + lines.push( + `compat deprecated=${report.compat.deprecatedCount} eligibleForRemoval=${report.compat.eligibleForRemovalCount}`, + ); + for (const record of report.compat.records) { + lines.push( + ` ${record.removeAfter ?? "no-date"} ${record.code} owner=${record.owner} codeRefs=${record.codeReferenceFiles.length} docRefs=${record.docReferenceFiles.length}`, + ); + } + lines.push(""); + lines.push( + `plugin-sdk entrypoints=${report.pluginSdk.entrypointCount} reserved=${report.pluginSdk.reservedCount} 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}`, + ); + 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}`, + ); + 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`); +} diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index b69a01a02e4..13b4188f623 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -34,9 +34,27 @@ type TsConfigJson = { type PackageJson = { name?: unknown; + version?: unknown; + private?: unknown; + type?: unknown; exports?: Record; devDependencies?: Record; }; +const MEMORY_HOST_SDK_EXPORTS = [ + "./engine", + "./engine-embeddings", + "./engine-foundation", + "./engine-qmd", + "./engine-storage", + "./multimodal", + "./query", + "./runtime", + "./runtime-cli", + "./runtime-core", + "./runtime-files", + "./secret", + "./status", +] as const; // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe JSON file shape. function readJsonFile(relativePath: string): T { @@ -183,4 +201,27 @@ describe("opt-in extension package boundaries", () => { false, ); }); + + it("keeps memory-host-sdk as a private package bridge over the core-owned implementation", () => { + const packageJson = readJsonFile("packages/memory-host-sdk/package.json"); + const packageExports = packageJson.exports as unknown as Record; + + expect(packageJson.name).toBe("@openclaw/memory-host-sdk"); + expect(packageJson.version).toBe("0.0.0-private"); + expect(packageJson.private).toBe(true); + expect(packageJson.type).toBe("module"); + expect(Object.keys(packageExports).toSorted()).toEqual([...MEMORY_HOST_SDK_EXPORTS]); + + for (const exportPath of MEMORY_HOST_SDK_EXPORTS) { + const target = packageExports[exportPath]; + expect(target, exportPath).toBe(`./src/${exportPath.slice(2)}.ts`); + if (!target) { + throw new Error(`Missing memory-host-sdk export target for ${exportPath}`); + } + const source = readFileSync(resolve(REPO_ROOT, "packages/memory-host-sdk", target), "utf8"); + expect(source.trim(), target).toBe( + `export * from "../../../src/memory-host-sdk/${exportPath.slice(2)}.js";`, + ); + } + }); }); 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 25985e259f4..d929cf62cf5 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -38,6 +38,7 @@ const DEPRECATED_TEST_BARREL_SPECIFIERS = new Set([ const DEPRECATED_TEST_BARREL_ALLOWED_REFERENCE_FILES = new Set([ "src/plugin-sdk/testing.ts", "src/plugin-sdk/test-utils.ts", + "packages/plugin-sdk/src/testing.ts", "src/plugins/compat/registry.ts", "src/plugins/contracts/plugin-entry-guardrails.test.ts", "src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts", @@ -361,6 +362,16 @@ function collectDeprecatedTestBarrelImports(): Array<{ file: string; specifier: return leaks; } +function collectDeprecatedPackageTestingBridgeDrift(): string[] { + const source = readFileSync( + resolve(REPO_ROOT, "packages/plugin-sdk/src/testing.ts"), + "utf8", + ).trim(); + return source === 'export * from "../../../src/plugin-sdk/testing.js";' + ? [] + : ["packages/plugin-sdk/src/testing.ts"]; +} + function parseTestApiNamedExports(source: string): string[] { const exports = new Set(); const declarationPattern = @@ -614,6 +625,10 @@ describe("plugin-sdk package contract guardrails", () => { expect(collectDeprecatedTestBarrelImports()).toEqual([]); }); + it("keeps the package testing barrel as a single deprecated bridge", () => { + expect(collectDeprecatedPackageTestingBridgeDrift()).toEqual([]); + }); + it("keeps extension test-api exports consumed", () => { expect(collectUnusedExtensionTestApiExports()).toEqual([]); });