Files
openclaw/scripts/check-extension-plugin-sdk-boundary.mjs
2026-03-18 07:04:50 -05:00

282 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const extensionsRoot = path.join(repoRoot, "extensions");
const MODES = new Set(["src-outside-plugin-sdk", "plugin-sdk-internal"]);
const baselinePathByMode = {
"src-outside-plugin-sdk": path.join(
repoRoot,
"test",
"fixtures",
"extension-src-outside-plugin-sdk-inventory.json",
),
"plugin-sdk-internal": path.join(
repoRoot,
"test",
"fixtures",
"extension-plugin-sdk-internal-inventory.json",
),
};
const ruleTextByMode = {
"src-outside-plugin-sdk":
"Rule: production extensions/** must not import src/** outside src/plugin-sdk/**",
"plugin-sdk-internal":
"Rule: production extensions/** must not import src/plugin-sdk-internal/**",
};
function normalizePath(filePath) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function isCodeFile(fileName) {
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(fileName);
}
function isTestLikeFile(relativePath) {
return (
/(^|\/)(__tests__|fixtures)\//.test(relativePath) ||
/(^|\/)[^/]*test-support\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath) ||
/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(relativePath)
);
}
async function collectExtensionSourceFiles(rootDir) {
const out = [];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === "dist" || entry.name === "node_modules") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (!entry.isFile() || !isCodeFile(entry.name)) {
continue;
}
const relativePath = normalizePath(fullPath);
if (isTestLikeFile(relativePath)) {
continue;
}
out.push(fullPath);
}
}
await walk(rootDir);
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
}
function toLine(sourceFile, node) {
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
}
function resolveSpecifier(specifier, importerFile) {
if (specifier.startsWith(".")) {
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
}
if (specifier.startsWith("/")) {
return normalizePath(specifier);
}
return null;
}
function classifyReason(mode, kind, resolvedPath) {
const verb =
kind === "export"
? "re-exports"
: kind === "dynamic-import"
? "dynamically imports"
: "imports";
if (mode === "plugin-sdk-internal") {
return `${verb} src/plugin-sdk-internal from an extension`;
}
if (resolvedPath.startsWith("src/plugin-sdk/")) {
return `${verb} allowed plugin-sdk path`;
}
return `${verb} core src path outside plugin-sdk from an extension`;
}
function compareEntries(left, right) {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier) ||
left.resolvedPath.localeCompare(right.resolvedPath) ||
left.reason.localeCompare(right.reason)
);
}
function shouldReport(mode, resolvedPath) {
if (!resolvedPath?.startsWith("src/")) {
return false;
}
if (mode === "plugin-sdk-internal") {
return resolvedPath.startsWith("src/plugin-sdk-internal/");
}
return !resolvedPath.startsWith("src/plugin-sdk/");
}
function collectFromSourceFile(mode, sourceFile, filePath) {
const entries = [];
function push(kind, specifierNode, specifier) {
const resolvedPath = resolveSpecifier(specifier, filePath);
if (!shouldReport(mode, resolvedPath)) {
return;
}
entries.push({
file: normalizePath(filePath),
line: toLine(sourceFile, specifierNode),
kind,
specifier,
resolvedPath,
reason: classifyReason(mode, kind, resolvedPath),
});
}
function visit(node) {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
} else if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length === 1 &&
ts.isStringLiteral(node.arguments[0])
) {
push("dynamic-import", node.arguments[0], node.arguments[0].text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
export async function collectExtensionPluginSdkBoundaryInventory(mode) {
if (!MODES.has(mode)) {
throw new Error(`Unknown mode: ${mode}`);
}
const files = await collectExtensionSourceFiles(extensionsRoot);
const inventory = [];
for (const filePath of files) {
const source = await fs.readFile(filePath, "utf8");
const scriptKind =
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
scriptKind,
);
inventory.push(...collectFromSourceFile(mode, sourceFile, filePath));
}
return inventory.toSorted(compareEntries);
}
export async function readExpectedInventory(mode) {
try {
return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8"));
} catch (error) {
if (
(mode === "plugin-sdk-internal" || mode === "src-outside-plugin-sdk") &&
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
) {
return [];
}
throw error;
}
}
export function diffInventory(expected, actual) {
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
return {
missing: expected
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
.toSorted(compareEntries),
unexpected: actual
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
.toSorted(compareEntries),
};
}
function formatInventoryHuman(mode, inventory) {
const lines = [ruleTextByMode[mode]];
if (inventory.length === 0) {
lines.push("No extension plugin-sdk boundary violations found.");
return lines.join("\n");
}
lines.push("Extension boundary inventory:");
let activeFile = "";
for (const entry of inventory) {
if (entry.file !== activeFile) {
activeFile = entry.file;
lines.push(activeFile);
}
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
lines.push(` specifier: ${entry.specifier}`);
lines.push(` resolved: ${entry.resolvedPath}`);
}
return lines.join("\n");
}
export async function main(argv = process.argv.slice(2)) {
const json = argv.includes("--json");
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
const mode = modeArg?.slice("--mode=".length) ?? "src-outside-plugin-sdk";
if (!MODES.has(mode)) {
throw new Error(`Unknown mode: ${mode}`);
}
const actual = await collectExtensionPluginSdkBoundaryInventory(mode);
if (json) {
process.stdout.write(`${JSON.stringify(actual, null, 2)}\n`);
return;
}
const expected = await readExpectedInventory(mode);
const diff = diffInventory(expected, actual);
console.log(formatInventoryHuman(mode, actual));
if (diff.missing.length === 0 && diff.unexpected.length === 0) {
console.log(`Baseline matches (${actual.length} entries).`);
return;
}
if (diff.missing.length > 0) {
console.error(`Missing baseline entries (${diff.missing.length}):`);
for (const entry of diff.missing) {
console.error(` - ${entry.file}:${entry.line} ${entry.reason}`);
}
}
if (diff.unexpected.length > 0) {
console.error(`Unexpected inventory entries (${diff.unexpected.length}):`);
for (const entry of diff.unexpected) {
console.error(` - ${entry.file}:${entry.line} ${entry.reason}`);
}
}
process.exitCode = 1;
}
if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
await main();
}