chore: add plugin boundary report

This commit is contained in:
Peter Steinberger 2026-04-28 04:12:27 +01:00
parent ae616777f3
commit 00e30ba8d9
No known key found for this signature in database
5 changed files with 388 additions and 0 deletions

View file

@ -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

View file

@ -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",

View file

@ -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<string>();
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<string>();
const docReferenceFiles = new Set<string>();
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<string>(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<string, string> };
const sourceBridgeFiles: string[] = [];
const packageCoreReferenceFiles = new Set<string>();
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`);
}

View file

@ -34,9 +34,27 @@ type TsConfigJson = {
type PackageJson = {
name?: unknown;
version?: unknown;
private?: unknown;
type?: unknown;
exports?: Record<string, { types?: unknown; default?: unknown }>;
devDependencies?: Record<string, string>;
};
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<T>(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<PackageJson>("packages/memory-host-sdk/package.json");
const packageExports = packageJson.exports as unknown as Record<string, string>;
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";`,
);
}
});
});

View file

@ -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<string>();
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([]);
});