Files
openclaw/scripts/check-extension-package-tsc-boundary.mjs
2026-04-10 19:23:10 +01:00

819 lines
24 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path, { dirname, 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";
const FAILURE_OUTPUT_TAIL_LINES = 40;
const SLOW_COMPILE_SUMMARY_LIMIT = 10;
const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".json"]);
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
"../../src/plugins/contracts/rootdir-boundary-canary.ts";
const ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT = "src/plugins/contracts/rootdir-boundary-canary.ts";
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 resolveCompileConcurrency() {
const raw = process.env.OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY;
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
return Math.max(1, Math.min(6, Math.floor(os.availableParallelism() / 2)));
}
function readJsonFile(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function summarizeOutputSection(name, output) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
if (lines.length <= FAILURE_OUTPUT_TAIL_LINES) {
return `${name}:\n${trimmed}`;
}
const omittedLineCount = lines.length - FAILURE_OUTPUT_TAIL_LINES;
const tail = lines.slice(-FAILURE_OUTPUT_TAIL_LINES).join("\n");
return `${name}:\n[... ${omittedLineCount} earlier lines omitted ...]\n${tail}`;
}
function formatFailureFooter(params = {}) {
const footerLines = [];
if (params.kind) {
footerLines.push(`kind: ${params.kind}`);
}
if (Number.isFinite(params.elapsedMs)) {
footerLines.push(`elapsed: ${params.elapsedMs}ms`);
}
if (params.note) {
footerLines.push(params.note);
}
return footerLines.join("\n");
}
export function formatBoundaryCheckSuccessSummary(params = {}) {
const lines = ["extension package boundary check passed"];
if (params.mode) {
lines.push(`mode: ${params.mode}`);
}
if (Number.isInteger(params.compileCount)) {
lines.push(`compiled plugins: ${params.compileCount}`);
}
if (Number.isInteger(params.skippedCompileCount) && params.skippedCompileCount > 0) {
lines.push(`skipped plugins: ${params.skippedCompileCount}`);
}
if (Number.isInteger(params.canaryCount)) {
lines.push(`canary plugins: ${params.canaryCount}`);
}
if (Number.isFinite(params.prepElapsedMs) && params.prepElapsedMs > 0) {
lines.push(`prep elapsed: ${params.prepElapsedMs}ms`);
}
if (Number.isFinite(params.compileElapsedMs) && params.compileElapsedMs > 0) {
lines.push(`compile elapsed: ${params.compileElapsedMs}ms`);
}
if (Number.isFinite(params.canaryElapsedMs) && params.canaryElapsedMs > 0) {
lines.push(`canary elapsed: ${params.canaryElapsedMs}ms`);
}
if (Number.isFinite(params.elapsedMs)) {
lines.push(`elapsed: ${params.elapsedMs}ms`);
}
return `${lines.join("\n")}\n`;
}
export function formatSkippedCompileProgress(params = {}) {
const skippedCount = params.skippedCount ?? 0;
const totalCount = params.totalCount ?? 0;
if (!Number.isInteger(skippedCount) || skippedCount <= 0) {
return "";
}
const staleCount = Math.max(0, totalCount - skippedCount);
if (staleCount > 0) {
return `skipped ${skippedCount} fresh plugin compiles before running ${staleCount} stale plugin checks\n`;
}
return `skipped ${skippedCount} fresh plugin compiles\n`;
}
export function formatSlowCompileSummary(params = {}) {
const compileTimings = Array.isArray(params.compileTimings) ? params.compileTimings : [];
if (compileTimings.length === 0) {
return "";
}
const limit =
Number.isInteger(params.limit) && params.limit > 0 ? params.limit : SLOW_COMPILE_SUMMARY_LIMIT;
const lines = ["slowest plugin compiles:"];
for (const timing of [...compileTimings]
.toSorted((left, right) => right.elapsedMs - left.elapsedMs)
.slice(0, limit)) {
lines.push(`- ${timing.extensionId}: ${timing.elapsedMs}ms`);
}
return `${lines.join("\n")}\n`;
}
export function formatStepFailure(label, params = {}) {
const stdoutSection = summarizeOutputSection("stdout", params.stdout ?? "");
const stderrSection = summarizeOutputSection("stderr", params.stderr ?? "");
const footer = formatFailureFooter(params);
return [label, stdoutSection, stderrSection, footer].filter(Boolean).join("\n\n");
}
function attachStepFailureMetadata(error, label, params = {}) {
error.stepLabel = label;
error.kind = params.kind ?? "unknown";
error.elapsedMs = params.elapsedMs ?? null;
error.fullOutput = [label, params.stdout ?? "", params.stderr ?? "", formatFailureFooter(params)]
.filter(Boolean)
.join("\n")
.trim();
return error;
}
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 isRelevantCompileInput(filePath) {
const basename = path.basename(filePath);
if (
basename === "__rootdir_boundary_canary__.ts" ||
basename === "tsconfig.rootdir-canary.json"
) {
return false;
}
if (basename.endsWith(".tsbuildinfo")) {
return false;
}
return COMPILE_INPUT_EXTENSIONS.has(path.extname(filePath));
}
function collectNewestMtime(entryPath, params = {}) {
const includeFile = params.includeFile ?? (() => true);
const skipDistDirectories = params.skipDistDirectories ?? true;
let newestMtimeMs = 0;
function visit(currentPath) {
if (!existsSync(currentPath)) {
return;
}
const stats = statSync(currentPath);
if (stats.isDirectory()) {
const basename = path.basename(currentPath);
if ((skipDistDirectories && basename === "dist") || basename === "node_modules") {
return;
}
for (const child of readdirSync(currentPath)) {
visit(path.join(currentPath, child));
}
return;
}
if (!includeFile(currentPath)) {
return;
}
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
}
visit(entryPath);
return newestMtimeMs;
}
function collectOldestMtime(paths) {
let oldestMtimeMs = Number.POSITIVE_INFINITY;
for (const entryPath of paths) {
if (!existsSync(entryPath)) {
return null;
}
oldestMtimeMs = Math.min(oldestMtimeMs, statSync(entryPath).mtimeMs);
}
return Number.isFinite(oldestMtimeMs) ? oldestMtimeMs : null;
}
export function isBoundaryCompileFresh(extensionId, params = {}) {
const rootDir = params.rootDir ?? repoRoot;
const extensionRoot = resolve(rootDir, "extensions", extensionId);
const extensionNewestInputMtimeMs =
params.extensionNewestInputMtimeMs ??
collectNewestMtime(extensionRoot, { includeFile: isRelevantCompileInput });
const sharedNewestInputMtimeMs =
params.sharedNewestInputMtimeMs ??
Math.max(
collectNewestMtime(resolve(rootDir, "dist/plugin-sdk"), {
skipDistDirectories: false,
}),
collectNewestMtime(resolve(rootDir, "packages/plugin-sdk/dist"), {
skipDistDirectories: false,
}),
);
const newestInputMtimeMs = Math.max(extensionNewestInputMtimeMs, sharedNewestInputMtimeMs);
const oldestOutputMtimeMs = collectOldestMtime([
resolveBoundaryTsStampPath(extensionId, rootDir),
]);
return oldestOutputMtimeMs !== null && oldestOutputMtimeMs >= newestInputMtimeMs;
}
function writeStampFile(filePath) {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${new Date().toISOString()}\n`, "utf8");
}
function runNodeStep(label, args, timeoutMs) {
const startedAt = Date.now();
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")
? `${label} timed out after ${timeoutMs}ms`
: "";
const errorSuffix = result.error ? result.error.message : "";
const note = [timeoutSuffix, errorSuffix].filter(Boolean).join("\n");
const elapsedMs = Date.now() - startedAt;
const kind = timeoutSuffix ? "timeout" : result.error ? "spawn-error" : "nonzero-exit";
const failure = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout: result.stdout,
stderr: result.stderr,
kind,
elapsedMs,
note,
}),
),
label,
{
stdout: result.stdout,
stderr: result.stderr,
kind,
elapsedMs,
note,
},
);
failure.status = result.status ?? 1;
throw failure;
}
function abortSiblingSteps(abortController) {
if (abortController && !abortController.signal.aborted) {
abortController.abort();
}
}
export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
const abortController = params.abortController;
const onFailure = params.onFailure;
const startedAt = Date.now();
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, args, {
cwd: repoRoot,
env: process.env,
signal: abortController?.signal,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
child.kill("SIGTERM");
settled = true;
const error = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout,
stderr,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
}),
),
label,
{
stdout,
stderr,
kind: "timeout",
elapsedMs: Date.now() - startedAt,
note: `${label} timed out after ${timeoutMs}ms`,
},
);
onFailure?.(error);
abortSiblingSteps(abortController);
rejectPromise(error);
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
if (settled) {
return;
}
clearTimeout(timer);
settled = true;
if (error.name === "AbortError" && abortController?.signal.aborted) {
rejectPromise(
attachStepFailureMetadata(new Error(`${label} canceled after sibling failure`), label, {
kind: "canceled",
elapsedMs: Date.now() - startedAt,
note: "canceled after sibling failure",
}),
);
return;
}
const failure = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout,
stderr,
kind: "spawn-error",
elapsedMs: Date.now() - startedAt,
note: error.message,
}),
),
label,
{
stdout,
stderr,
kind: "spawn-error",
elapsedMs: Date.now() - startedAt,
note: error.message,
},
);
onFailure?.(failure);
abortSiblingSteps(abortController);
rejectPromise(failure);
});
child.on("close", (code) => {
if (settled) {
return;
}
clearTimeout(timer);
settled = true;
if (code === 0) {
resolvePromise({ stdout, stderr, elapsedMs: Date.now() - startedAt });
return;
}
const error = attachStepFailureMetadata(
new Error(
formatStepFailure(label, {
stdout,
stderr,
kind: "nonzero-exit",
elapsedMs: Date.now() - startedAt,
}),
),
label,
{
stdout,
stderr,
kind: "nonzero-exit",
elapsedMs: Date.now() - startedAt,
},
);
onFailure?.(error);
abortSiblingSteps(abortController);
rejectPromise(error);
});
});
}
export async function runNodeStepsWithConcurrency(steps, concurrency) {
const abortController = new AbortController();
let firstFailure = null;
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, steps.length) }, async () => {
while (true) {
if (abortController.signal.aborted) {
return;
}
const index = nextIndex;
nextIndex += 1;
if (index >= steps.length) {
return;
}
const step = steps[index];
step.onStart?.();
const result = await runNodeStepAsync(step.label, step.args, step.timeoutMs, {
abortController,
onFailure(error) {
firstFailure ??= error;
},
});
step.onSuccess?.(result);
}
});
await Promise.allSettled(workers);
if (firstFailure) {
throw firstFailure;
}
}
export function resolveCanaryArtifactPaths(extensionId, rootDir = repoRoot) {
const extensionRoot = resolve(rootDir, "extensions", extensionId);
return {
extensionRoot,
canaryPath: resolve(extensionRoot, "__rootdir_boundary_canary__.ts"),
tsconfigPath: resolve(extensionRoot, "tsconfig.rootdir-canary.json"),
};
}
export function cleanupCanaryArtifacts(extensionId, rootDir = repoRoot) {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId, rootDir);
rmSync(canaryPath, { force: true });
rmSync(tsconfigPath, { force: true });
}
export function cleanupCanaryArtifactsForExtensions(extensionIds, rootDir = repoRoot) {
for (const extensionId of extensionIds) {
cleanupCanaryArtifacts(extensionId, rootDir);
}
}
export function installCanaryArtifactCleanup(extensionIds, params = {}) {
const rootDir = params.rootDir ?? repoRoot;
const processObject = params.processObject ?? process;
const exitHandler = () => {
cleanupCanaryArtifactsForExtensions(extensionIds, rootDir);
};
processObject.on("exit", exitHandler);
return () => {
processObject.off("exit", exitHandler);
};
}
function resolveBoundaryTsBuildInfoPath(extensionId) {
return resolve(repoRoot, "extensions", extensionId, "dist", ".boundary-tsc.tsbuildinfo");
}
function resolveBoundaryTsStampPath(extensionId, rootDir = repoRoot) {
return resolve(rootDir, "extensions", extensionId, "dist", ".boundary-tsc.stamp");
}
export function resolveBoundaryCheckLockPath(rootDir = repoRoot) {
return resolve(rootDir, "dist", ".extension-package-boundary.lock");
}
function resolveBoundaryCheckLockOwnerPath(lockPath) {
return join(lockPath, "owner.json");
}
function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM");
}
}
function removeStaleBoundaryCheckLock(lockPath) {
const ownerPath = resolveBoundaryCheckLockOwnerPath(lockPath);
let owner;
try {
owner = JSON.parse(readFileSync(ownerPath, "utf8"));
} catch {
rmSync(lockPath, { force: true, recursive: true });
return true;
}
if (owner && typeof owner === "object" && isProcessAlive(owner.pid)) {
return false;
}
rmSync(lockPath, { force: true, recursive: true });
return true;
}
export function acquireBoundaryCheckLock(params = {}) {
const rootDir = params.rootDir ?? repoRoot;
const processObject = params.processObject ?? process;
const lockPath = resolveBoundaryCheckLockPath(rootDir);
mkdirSync(dirname(lockPath), { recursive: true });
try {
mkdirSync(lockPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
if (removeStaleBoundaryCheckLock(lockPath)) {
mkdirSync(lockPath);
} else {
throw attachStepFailureMetadata(
new Error(
[
"extension package boundary check",
"kind: lock-contention",
`lock: ${lockPath}`,
"another extension package boundary check is already running in this checkout",
].join("\n\n"),
{ cause: error },
),
"extension package boundary check",
{
kind: "lock-contention",
note: `lock: ${lockPath}\nanother extension package boundary check is already running in this checkout`,
},
);
}
} else {
throw error;
}
}
writeFileSync(
resolveBoundaryCheckLockOwnerPath(lockPath),
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
const release = () => {
rmSync(lockPath, { force: true, recursive: true });
};
processObject.on("exit", release);
return () => {
processObject.off("exit", release);
release();
};
}
async function runCompileCheck(extensionIds) {
const prepStartedAt = Date.now();
process.stdout.write(
`preparing plugin-sdk boundary artifacts for ${extensionIds.length} plugins\n`,
);
runNodeStep("plugin-sdk boundary prep", [prepareBoundaryArtifactsBin], 420_000);
const prepElapsedMs = Date.now() - prepStartedAt;
const concurrency = resolveCompileConcurrency();
const verboseFreshLogs = process.env.OPENCLAW_EXTENSION_BOUNDARY_VERBOSE_FRESH === "1";
const sharedNewestInputMtimeMs = Math.max(
collectNewestMtime(resolve(repoRoot, "dist/plugin-sdk"), {
skipDistDirectories: false,
}),
collectNewestMtime(resolve(repoRoot, "packages/plugin-sdk/dist"), {
skipDistDirectories: false,
}),
);
process.stdout.write(`compile concurrency ${concurrency}\n`);
const compileStartedAt = Date.now();
let skippedCompileCount = 0;
const compileTimings = [];
const steps = extensionIds
.map((extensionId, index) => {
const tsBuildInfoPath = resolveBoundaryTsBuildInfoPath(extensionId);
const extensionNewestInputMtimeMs = collectNewestMtime(
resolve(repoRoot, "extensions", extensionId),
{
includeFile: isRelevantCompileInput,
},
);
mkdirSync(dirname(tsBuildInfoPath), { recursive: true });
if (
isBoundaryCompileFresh(extensionId, {
extensionNewestInputMtimeMs,
sharedNewestInputMtimeMs,
})
) {
skippedCompileCount += 1;
if (verboseFreshLogs) {
process.stdout.write(
`[${index + 1}/${extensionIds.length}] ${extensionId} (fresh; skipping)\n`,
);
}
return null;
}
return {
label: extensionId,
onStart() {
process.stdout.write(`[${index + 1}/${extensionIds.length}] ${extensionId}\n`);
},
onSuccess(result) {
writeStampFile(resolveBoundaryTsStampPath(extensionId));
compileTimings.push({
extensionId,
elapsedMs: result.elapsedMs,
});
},
args: [
tscBin,
"-p",
resolve(repoRoot, "extensions", extensionId, "tsconfig.json"),
"--noEmit",
"--incremental",
"--tsBuildInfoFile",
tsBuildInfoPath,
],
timeoutMs: 120_000,
};
})
.filter(Boolean);
if (!verboseFreshLogs && skippedCompileCount > 0) {
process.stdout.write(
formatSkippedCompileProgress({
skippedCount: skippedCompileCount,
totalCount: extensionIds.length,
}),
);
}
if (steps.length > 0) {
await runNodeStepsWithConcurrency(steps, concurrency);
}
return {
prepElapsedMs,
compileCount: steps.length,
skippedCompileCount,
compileElapsedMs: Date.now() - compileStartedAt,
compileTimings,
};
}
async function runCanaryCheck(extensionIds) {
const startedAt = Date.now();
await Promise.all(
extensionIds.map(async (extensionId, index) => {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId);
cleanupCanaryArtifacts(extensionId);
process.stdout.write(`[${index + 1}/${extensionIds.length}] ${extensionId} canary\n`);
try {
writeFileSync(
canaryPath,
[
`import { ROOTDIR_BOUNDARY_CANARY } from "${ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH}";`,
"void ROOTDIR_BOUNDARY_CANARY;",
"export {};",
"",
].join("\n"),
"utf8",
);
writeFileSync(
tsconfigPath,
`${JSON.stringify(
{
extends: "./tsconfig.json",
include: ["./__rootdir_boundary_canary__.ts"],
exclude: [],
},
null,
2,
)}\n`,
"utf8",
);
const result = await runNodeStepAsync(
`${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 && typeof error.fullOutput === "string"
? error.fullOutput
: String(error);
if (!output.includes("TS6059") || !output.includes(ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT)) {
throw error;
}
} finally {
cleanupCanaryArtifacts(extensionId);
}
}),
);
return {
canaryElapsedMs: Date.now() - startedAt,
};
}
export async function main(argv = process.argv.slice(2)) {
const startedAt = Date.now();
const mode = parseMode(argv);
const optInExtensionIds = collectOptInExtensionIds();
const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds);
const cleanupExtensionIds = optInExtensionIds;
const shouldRunCanary = mode === "all" || mode === "canary";
const releaseBoundaryLock = acquireBoundaryCheckLock();
const teardownCanaryCleanup = installCanaryArtifactCleanup(cleanupExtensionIds);
let prepElapsedMs;
let compileCount = 0;
let skippedCompileCount = 0;
let compileElapsedMs;
let compileTimings = [];
let canaryElapsedMs;
try {
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
if (mode === "all" || mode === "compile") {
({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } =
await runCompileCheck(optInExtensionIds));
}
if (shouldRunCanary) {
({ canaryElapsedMs } = await runCanaryCheck(canaryExtensionIds));
}
process.stdout.write(
formatBoundaryCheckSuccessSummary({
mode,
compileCount,
skippedCompileCount,
canaryCount: shouldRunCanary ? canaryExtensionIds.length : 0,
prepElapsedMs,
compileElapsedMs,
canaryElapsedMs,
elapsedMs: Date.now() - startedAt,
}),
);
process.stdout.write(
formatSlowCompileSummary({
compileTimings,
}),
);
} finally {
releaseBoundaryLock?.();
teardownCanaryCleanup?.();
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
}
}
if (import.meta.main) {
await main();
}