openclaw/scripts/plugin-sdk-shipped-surface.mts
Peter Steinberger 28d37607e9
fix(plugin-sdk): block removal of shipped stable SDK surface without a dated compat record (#167036)
Related: #166404

## What Problem This Solves

Fixes: shipped Plugin SDK subpaths and exports could be removed without the surface check noticing, which broke published third-party plugins such as `@tencent-weixin/openclaw-weixin` on 2026.10.1-beta.1.

## User Impact

User impact: the stable Plugin SDK surface can no longer silently disappear. Removing a subpath or named export that shipped in the latest stable release now fails `pnpm plugin-sdk:surface:check` unless a compatibility record covers it with a final compatibility day that has passed. No runtime behavior changes; nothing is restored here (restoring the removed subpaths for #166404 is a separate product decision).

## Why This Change Was Made

`plugin-sdk:surface:check` only enforced maximum export-count budgets, and the API baseline diff is report-only. The commits that retired `config-runtime`, `infra-runtime`, `channel-message`, `command-auth` and four other subpaths (912+ named exports that shipped in v2026.9.8) lowered those budgets in the same change, so the check passed. A ceiling cannot detect a removal.

This adds a committed inventory of the latest stable release's typed public surface (`scripts/lib/plugin-sdk-shipped-surface.json`, pinned to the current npm `latest` v2026.9.9: 158 typed subpaths, 4,591 named exports, identical to v2026.9.8). It is generated from the release tag's package exports and declarations by `pnpm plugin-sdk:shipped-surface:gen -- --release <tag>`, reusing the existing tagged-revision renderer from `plugin-sdk:api:diff`, and is regenerated during stable closeout (documented in RELEASING.md). The surface check compares the current tree against it:

- A missing shipped subpath, or a missing name in a surviving subpath, needs a compat record whose `surfaces` names `openclaw/plugin-sdk/<subpath>` (whole subpath) or the qualified `openclaw/plugin-sdk/<subpath>.<Name>`. Bare names do not count.
- The record must be `deprecated`, `removal-pending`, or `removed` with a `removeAfter` strictly before the current UTC date (the date is the final compatibility day). A `removalGate` alone or a future date does not authorize; budgets play no role.
- Packaged private runtime facades without a `types` condition are excluded: they are not a declared contract.

To make current `main` pass without restoring anything, the already-landed removals are recorded honestly:

- Dated tombstones for the five subpaths retired under the Sept 30 owner decision (`removeAfter` 2026-09-30) and new tombstones for `command-auth`, `discord`, `telegram-account`, whose records were deleted in 0d530ea5 (`removeAfter` 2026-10-01).
- 14 later named-export removals from surviving subpaths (2fec39f5 #165431, bd64d93f #165288, f2de06b3 #163587) recorded retroactively per owner decision; each release note states the export was removed without a compatibility window.
- The Skill Workshop proposal-hook types removed by cfee8511 (#161057) already had a tombstone; its surfaces are now qualified and dated.

Release-note context: Plugin SDK authors get a migration-guide row for each of the 14 retroactively recorded exports in `docs/plugins/sdk-migration/removed-surfaces.md`.

## Evidence

- Inventory generation from tag `v2026.9.8` (fc23bc86): 158 typed subpaths, 4,591 names; `config-runtime` 115, `infra-runtime` 583, `channel-message` 136, `command-auth` 78 names.
- Historical transition, guard + inventory overlaid on throwaway worktrees with `OPENCLAW_PLUGIN_SDK_SURFACE_NOW=2026-10-01`: `e649be31^` shows only pre-existing gaps; `e649be31` additionally fails on exactly `channel-lifecycle` (23), `channel-message` (136), `channel-reply-pipeline` (12), `config-runtime` (115), `infra-runtime` (583) despite its lowered budgets.
- Final tree: `pnpm plugin-sdk:surface:check` → `shipped Plugin SDK v2026.9.8: no unauthorized removals`. Reverting the tombstone dates re-reports all eight subpaths; pinning `OPENCLAW_PLUGIN_SDK_SURFACE_NOW=2026-10-07` re-reports the eight Skill Workshop types (strict date semantics).
- The guard already caught a live regression during this PR: cfee8511 landed on main mid-review and the rebased check failed on its eight unqualified types until the record was fixed.
- Regenerated against v2026.9.9 (bcfc8881) after it went stable mid-review: zero subpaths or names added or dropped versus v2026.9.8; check still clean.
- PR wrapper inventory lists the new compat-record module (first CI run failed `eager-import-closure` / `pr-worktree-provision` on its absence).
- Focused tests (`--maxWorkers=1`): `test/scripts/plugin-sdk-shipped-surface.test.ts` 20 passed (~2.2 s wall), `src/plugins/compat/registry.test.ts` 12 passed, `test/scripts/plugin-sdk-surface-report.test.ts` 11 passed / 1 Linux-only skip; all three ~11.7 s wall. Pure evaluator tests, no TS project boots, no timers.
- `node scripts/check-changed.mjs`: every lane passed except two inherited from `main`: the dead-export scan (`observePlacementAuthority`, already fixed on main by 017842386a) and core lint on three over-length files this PR does not touch. Direct oxlint on all changed `.ts`/`.mts` files is clean.
- Autoreview (Codex, P2 threshold): scoped-clean.
2026-10-08 11:14:48 -07:00

98 lines
3.5 KiB
TypeScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { format } from "oxfmt";
import {
compareStableReleases,
isStableRelease,
parsePluginSdkShippedSurface,
shippedSurfacePath,
typedPluginSdkSubpaths,
} from "./lib/plugin-sdk-shipped-surface.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { renderPluginSdkApiRevisions } from "./plugin-sdk-api-diff.mts";
async function main() {
const args = process.argv.slice(2).filter((arg) => arg !== "--");
const release = args[1];
if (args.length !== 2 || args[0] !== "--release" || !release) {
throw new Error("Usage: pnpm plugin-sdk:shipped-surface:gen -- --release <stable tag>");
}
if (!isStableRelease(release)) {
throw new Error(`Expected a stable release tag (vX.Y.Z), got ${release}`);
}
const repoRoot = resolveRepoRoot(import.meta.url);
const git = (gitArgs: string[]) =>
execFileSync("git", gitArgs, { cwd: repoRoot, encoding: "utf8" }).trim();
const output = path.join(repoRoot, shippedSurfacePath);
let existing: string | undefined;
try {
existing = await fs.readFile(output, "utf8");
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
throw error;
}
}
const committed = git(["ls-tree", "HEAD", "--", shippedSurfacePath])
? git(["show", `HEAD:${shippedSurfacePath}`])
: undefined;
for (const content of [committed, existing]) {
if (content === undefined) {
continue;
}
const inventory = parsePluginSdkShippedSurface(JSON.parse(content));
if (compareStableReleases(release, inventory.release) < 0) {
throw new Error(`Refusing to replace ${inventory.release} with older release ${release}`);
}
}
const commit = git(["rev-parse", "--verify", `refs/tags/${release}^{commit}`]);
const subpaths = typedPluginSdkSubpaths(JSON.parse(git(["show", `${commit}:package.json`])));
const rendered = (await renderPluginSdkApiRevisions(repoRoot, [commit])).get(commit);
if (!rendered) {
throw new Error(`No rendered Plugin SDK surface for ${release}`);
}
const modules = new Map(rendered.modules.map((module) => [module.entrypoint, module]));
const entrypoints = Object.fromEntries(
subpaths.map((subpath) => {
const module = modules.get(subpath);
if (!module) {
throw new Error(`Typed package subpath ${subpath} in ${release} has no rendered module`);
}
return [
subpath,
module.exports
.map((entry) => entry.exportName)
.filter((name) => name !== "default")
.toSorted(),
];
}),
);
const inventory = parsePluginSdkShippedSurface({
commit,
entrypoints,
release,
schema: "openclaw.plugin-sdk-shipped-surface/v1",
});
const result = await format(
shippedSurfacePath,
JSON.stringify({
commit: inventory.commit,
entrypoints: inventory.entrypoints,
release: inventory.release,
schema: inventory.schema,
}),
);
if (result.errors.length) {
throw new Error(`Could not format ${shippedSurfacePath}: ${JSON.stringify(result.errors)}`);
}
await fs.writeFile(output, result.code);
console.log(
`Generated ${shippedSurfacePath} from ${release} (${commit}): ${subpaths.length} typed subpaths, ${Object.values(entrypoints).reduce((count, names) => count + names.length, 0)} named exports.`,
);
}
await main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode ||= 1;
});