From 4329d94de3a71803db177ee50af68eb75d7ea06b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 7 Apr 2026 10:15:03 +0100 Subject: [PATCH] fix(plugins): stabilize package boundary tsc checks --- extensions/openai/provider-policy-api.ts | 2 +- package.json | 1 + .../check-extension-package-tsc-boundary.mjs | 170 ++++++++++++++++++ test/extension-package-tsc-boundary.test.ts | 70 +------- 4 files changed, 178 insertions(+), 65 deletions(-) create mode 100644 scripts/check-extension-package-tsc-boundary.mjs diff --git a/extensions/openai/provider-policy-api.ts b/extensions/openai/provider-policy-api.ts index 9b052e65c64..c2fbb0cb4a4 100644 --- a/extensions/openai/provider-policy-api.ts +++ b/extensions/openai/provider-policy-api.ts @@ -1,4 +1,4 @@ -import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types"; export function normalizeConfig(params: { provider: string; providerConfig: ModelProviderConfig }) { return params.providerConfig; diff --git a/package.json b/package.json index d1ef289e04e..ad08a39a116 100644 --- a/package.json +++ b/package.json @@ -1211,6 +1211,7 @@ "test:extensions": "node scripts/run-vitest.mjs run --config vitest.extensions.config.ts", "test:extensions:batch": "node scripts/test-extension-batch.mjs", "test:extensions:memory": "node scripts/profile-extension-memory.mjs", + "test:extensions:package-boundary": "node scripts/check-extension-package-tsc-boundary.mjs", "test:fast": "node scripts/run-vitest.mjs run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", "test:gateway": "node scripts/run-vitest.mjs run --config vitest.gateway.config.ts", diff --git a/scripts/check-extension-package-tsc-boundary.mjs b/scripts/check-extension-package-tsc-boundary.mjs new file mode 100644 index 00000000000..f91843fd7ee --- /dev/null +++ b/scripts/check-extension-package-tsc-boundary.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; + +const require = createRequire(import.meta.url); +const repoRoot = resolve(import.meta.dirname, ".."); +const tscBin = require.resolve("typescript/bin/tsc"); +const prepareBoundaryArtifactsBin = resolve( + repoRoot, + "scripts/prepare-extension-package-boundary-artifacts.mjs", +); +const extensionPackageBoundaryBaseConfig = "../tsconfig.package-boundary.base.json"; + +function parseMode(argv) { + const modeArg = argv.find((arg) => arg.startsWith("--mode=")); + const mode = modeArg?.slice("--mode=".length) ?? "all"; + if (!new Set(["all", "compile", "canary"]).has(mode)) { + throw new Error(`Unknown mode: ${mode}`); + } + return mode; +} + +function readJsonFile(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function collectBundledExtensionIds() { + return readdirSync(join(repoRoot, "extensions"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted(); +} + +function resolveExtensionTsconfigPath(extensionId) { + return join(repoRoot, "extensions", extensionId, "tsconfig.json"); +} + +function readExtensionTsconfig(extensionId) { + return readJsonFile(resolveExtensionTsconfigPath(extensionId)); +} + +function collectOptInExtensionIds() { + return collectBundledExtensionIds().filter((extensionId) => { + const tsconfigPath = resolveExtensionTsconfigPath(extensionId); + if (!existsSync(tsconfigPath)) { + return false; + } + return readExtensionTsconfig(extensionId).extends === extensionPackageBoundaryBaseConfig; + }); +} + +function collectCanaryExtensionIds(extensionIds) { + return [ + ...new Map( + extensionIds.map((extensionId) => [ + JSON.stringify(readExtensionTsconfig(extensionId)), + extensionId, + ]), + ).values(), + ]; +} + +function runNodeStep(label, args, timeoutMs) { + const result = spawnSync(process.execPath, args, { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + timeout: timeoutMs, + }); + + if (result.status === 0 && !result.error) { + return result; + } + + const timeoutSuffix = + result.error?.name === "Error" && result.error.message.includes("ETIMEDOUT") + ? `\n${label} timed out after ${timeoutMs}ms` + : ""; + const errorSuffix = result.error ? `\n${result.error.message}` : ""; + const failure = new Error( + `${label}\n${result.stdout}${result.stderr}${timeoutSuffix}${errorSuffix}`.trim(), + ); + failure.status = result.status ?? 1; + throw failure; +} + +function cleanupCanaryArtifacts(extensionId) { + const extensionRoot = resolve(repoRoot, "extensions", extensionId); + rmSync(resolve(extensionRoot, "__rootdir_boundary_canary__.ts"), { force: true }); + rmSync(resolve(extensionRoot, "tsconfig.rootdir-canary.json"), { force: true }); +} + +function runCompileCheck(extensionIds) { + process.stdout.write( + `preparing plugin-sdk boundary artifacts for ${extensionIds.length} plugins\n`, + ); + runNodeStep("plugin-sdk boundary prep", [prepareBoundaryArtifactsBin], 420_000); + for (const [index, extensionId] of extensionIds.entries()) { + process.stdout.write(`[${index + 1}/${extensionIds.length}] ${extensionId}\n`); + runNodeStep( + extensionId, + [tscBin, "-p", resolve(repoRoot, "extensions", extensionId, "tsconfig.json"), "--noEmit"], + 120_000, + ); + } +} + +function runCanaryCheck(extensionIds) { + for (const extensionId of extensionIds) { + const extensionRoot = resolve(repoRoot, "extensions", extensionId); + const canaryPath = resolve(extensionRoot, "__rootdir_boundary_canary__.ts"); + const tsconfigPath = resolve(extensionRoot, "tsconfig.rootdir-canary.json"); + + cleanupCanaryArtifacts(extensionId); + try { + writeFileSync( + canaryPath, + 'import * as foo from "../../src/cli/acp-cli.ts";\nvoid foo;\nexport {};\n', + "utf8", + ); + writeFileSync( + tsconfigPath, + `${JSON.stringify( + { + extends: "./tsconfig.json", + include: ["./__rootdir_boundary_canary__.ts"], + exclude: [], + }, + null, + 2, + )}\n`, + "utf8", + ); + + const result = runNodeStep( + `${extensionId} canary`, + [tscBin, "-p", tsconfigPath, "--noEmit"], + 120_000, + ); + throw new Error( + `${extensionId} canary unexpectedly passed\n${result.stdout}${result.stderr}`, + ); + } catch (error) { + const output = error instanceof Error ? error.message : String(error); + if (!output.includes("TS6059") || !output.includes("src/cli/acp-cli.ts")) { + throw error; + } + } finally { + cleanupCanaryArtifacts(extensionId); + } + } +} + +function main() { + const mode = parseMode(process.argv.slice(2)); + const optInExtensionIds = collectOptInExtensionIds(); + const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds); + + if (mode === "all" || mode === "compile") { + runCompileCheck(optInExtensionIds); + } + if (mode === "all" || mode === "canary") { + runCanaryCheck(canaryExtensionIds); + } +} + +main(); diff --git a/test/extension-package-tsc-boundary.test.ts b/test/extension-package-tsc-boundary.test.ts index 14186e63ef7..95f15cb7602 100644 --- a/test/extension-package-tsc-boundary.test.ts +++ b/test/extension-package-tsc-boundary.test.ts @@ -1,29 +1,12 @@ import { spawnSync } from "node:child_process"; -import { rmSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { - collectOptInExtensionPackageBoundaries, - readExtensionPackageBoundaryTsconfig, -} from "../scripts/lib/extension-package-boundary.ts"; const REPO_ROOT = resolve(import.meta.dirname, ".."); -const PREPARE_BOUNDARY_ARTIFACTS_BIN = resolve( +const CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN = resolve( REPO_ROOT, - "scripts/prepare-extension-package-boundary-artifacts.mjs", + "scripts/check-extension-package-tsc-boundary.mjs", ); -const require = createRequire(import.meta.url); -const TSC_BIN = require.resolve("typescript/bin/tsc"); -const OPT_IN_EXTENSION_IDS = collectOptInExtensionPackageBoundaries(REPO_ROOT); -const CANARY_EXTENSION_IDS = [ - ...new Map( - OPT_IN_EXTENSION_IDS.map((extensionId) => [ - JSON.stringify(readExtensionPackageBoundaryTsconfig(extensionId, REPO_ROOT)), - extensionId, - ]), - ).values(), -]; function runNode(args: string[], timeout: number) { return spawnSync(process.execPath, args, { @@ -36,53 +19,12 @@ function runNode(args: string[], timeout: number) { describe("opt-in extension package TypeScript boundaries", () => { it("typechecks each opt-in extension cleanly through @openclaw/plugin-sdk", () => { - const prepareResult = runNode([PREPARE_BOUNDARY_ARTIFACTS_BIN], 420_000); - expect(prepareResult.status, `${prepareResult.stdout}\n${prepareResult.stderr}`).toBe(0); - - for (const extensionId of OPT_IN_EXTENSION_IDS) { - const result = runNode( - [TSC_BIN, "-p", resolve(REPO_ROOT, "extensions", extensionId, "tsconfig.json"), "--noEmit"], - 120_000, - ); - expect(result.status, `${extensionId}\n${result.stdout}\n${result.stderr}`).toBe(0); - } + const result = runNode([CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN, "--mode=compile"], 420_000); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); }, 300_000); it("fails when opt-in extensions import src/cli through a relative path", () => { - for (const extensionId of CANARY_EXTENSION_IDS) { - const extensionRoot = resolve(REPO_ROOT, "extensions", extensionId); - const canaryPath = resolve(extensionRoot, "__rootdir_boundary_canary__.ts"); - const tsconfigPath = resolve(extensionRoot, "tsconfig.rootdir-canary.json"); - - try { - writeFileSync( - canaryPath, - 'import * as foo from "../../src/cli/acp-cli.ts";\nvoid foo;\nexport {};\n', - "utf8", - ); - writeFileSync( - tsconfigPath, - JSON.stringify( - { - extends: "./tsconfig.json", - include: ["./__rootdir_boundary_canary__.ts"], - exclude: [], - }, - null, - 2, - ), - "utf8", - ); - - const result = runNode([TSC_BIN, "-p", tsconfigPath, "--noEmit"], 120_000); - const output = `${result.stdout}\n${result.stderr}`; - expect(result.status).not.toBe(0); - expect(output).toContain("TS6059"); - expect(output).toContain("src/cli/acp-cli.ts"); - } finally { - rmSync(canaryPath, { force: true }); - rmSync(tsconfigPath, { force: true }); - } - } + const result = runNode([CHECK_EXTENSION_PACKAGE_BOUNDARY_BIN, "--mode=canary"], 180_000); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); }); });