Add plugin-sdk seam audit script

This commit is contained in:
Tak Hoffman
2026-03-18 10:16:21 -05:00
parent 5d41fd4497
commit ea476de1e4

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env node
import fs from "node:fs";
import { builtinModules } from "node:module";
import path from "node:path";
import process from "node:process";
const REPO_ROOT = process.cwd();
const SCAN_ROOTS = ["src", "extensions", "scripts", "ui", "test"];
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "coverage", ".turbo", ".next", "build"]);
const BUILTIN_PREFIXES = new Set(["node:"]);
const BUILTIN_MODULES = new Set(
builtinModules.flatMap((name) => [name, name.replace(/^node:/, "")]),
);
const INTERNAL_PREFIXES = ["openclaw/plugin-sdk", "openclaw/", "@/", "~/", "#"];
const compareStrings = (a, b) => a.localeCompare(b);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function normalizeSlashes(input) {
return input.split(path.sep).join("/");
}
function listFiles(rootRel) {
const rootAbs = path.join(REPO_ROOT, rootRel);
if (!fs.existsSync(rootAbs)) {
return [];
}
const out = [];
const stack = [rootAbs];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(current, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) {
stack.push(abs);
}
continue;
}
if (!entry.isFile()) {
continue;
}
if (!CODE_EXTENSIONS.has(path.extname(entry.name))) {
continue;
}
out.push(abs);
}
}
out.sort((a, b) =>
normalizeSlashes(path.relative(REPO_ROOT, a)).localeCompare(
normalizeSlashes(path.relative(REPO_ROOT, b)),
),
);
return out;
}
function extractSpecifiers(sourceText) {
const specifiers = [];
const patterns = [
/\bimport\s+type\s+[^"'`]*?\sfrom\s+["'`]([^"'`]+)["'`]/g,
/\bimport\s+[^"'`]*?\sfrom\s+["'`]([^"'`]+)["'`]/g,
/\bexport\s+[^"'`]*?\sfrom\s+["'`]([^"'`]+)["'`]/g,
/\bimport\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g,
];
for (const pattern of patterns) {
for (const match of sourceText.matchAll(pattern)) {
const specifier = match[1]?.trim();
if (specifier) {
specifiers.push(specifier);
}
}
}
return specifiers;
}
function toRepoRelative(absPath) {
return normalizeSlashes(path.relative(REPO_ROOT, absPath));
}
function resolveRelativeImport(fileAbs, specifier) {
if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
return null;
}
const fromDir = path.dirname(fileAbs);
const baseAbs = specifier.startsWith("/")
? path.join(REPO_ROOT, specifier)
: path.resolve(fromDir, specifier);
const candidatePaths = [
baseAbs,
`${baseAbs}.ts`,
`${baseAbs}.tsx`,
`${baseAbs}.mts`,
`${baseAbs}.cts`,
`${baseAbs}.js`,
`${baseAbs}.jsx`,
`${baseAbs}.mjs`,
`${baseAbs}.cjs`,
path.join(baseAbs, "index.ts"),
path.join(baseAbs, "index.tsx"),
path.join(baseAbs, "index.mts"),
path.join(baseAbs, "index.cts"),
path.join(baseAbs, "index.js"),
path.join(baseAbs, "index.jsx"),
path.join(baseAbs, "index.mjs"),
path.join(baseAbs, "index.cjs"),
];
for (const candidate of candidatePaths) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return toRepoRelative(candidate);
}
}
return normalizeSlashes(path.relative(REPO_ROOT, baseAbs));
}
function getExternalPackageRoot(specifier) {
if (!specifier) {
return null;
}
if (!/^[a-zA-Z0-9@][a-zA-Z0-9@._/+:-]*$/.test(specifier)) {
return null;
}
if (specifier.startsWith(".") || specifier.startsWith("/")) {
return null;
}
if (Array.from(BUILTIN_PREFIXES).some((prefix) => specifier.startsWith(prefix))) {
return null;
}
if (
INTERNAL_PREFIXES.some((prefix) => specifier === prefix || specifier.startsWith(`${prefix}/`))
) {
return null;
}
if (BUILTIN_MODULES.has(specifier)) {
return null;
}
if (specifier.startsWith("@")) {
const [scope, name] = specifier.split("/");
return scope && name ? `${scope}/${name}` : specifier;
}
const root = specifier.split("/")[0] ?? specifier;
if (BUILTIN_MODULES.has(root)) {
return null;
}
return root;
}
function ensureArrayMap(map, key) {
if (!map.has(key)) {
map.set(key, []);
}
return map.get(key);
}
const packageJson = readJson(path.join(REPO_ROOT, "package.json"));
const declaredPackages = new Set([
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.devDependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
...Object.keys(packageJson.optionalDependencies ?? {}),
]);
const fileRecords = [];
const publicSeamUsage = new Map();
const sourceSeamUsage = new Map();
const missingExternalUsage = new Map();
for (const root of SCAN_ROOTS) {
for (const fileAbs of listFiles(root)) {
const fileRel = toRepoRelative(fileAbs);
const sourceText = fs.readFileSync(fileAbs, "utf8");
const specifiers = extractSpecifiers(sourceText);
const publicSeams = new Set();
const sourceSeams = new Set();
const externalPackages = new Set();
for (const specifier of specifiers) {
if (specifier === "openclaw/plugin-sdk") {
publicSeams.add("index");
ensureArrayMap(publicSeamUsage, "index").push(fileRel);
continue;
}
if (specifier.startsWith("openclaw/plugin-sdk/")) {
const seam = specifier.slice("openclaw/plugin-sdk/".length);
publicSeams.add(seam);
ensureArrayMap(publicSeamUsage, seam).push(fileRel);
continue;
}
const resolvedRel = resolveRelativeImport(fileAbs, specifier);
if (resolvedRel?.startsWith("src/plugin-sdk/")) {
const seam = resolvedRel
.slice("src/plugin-sdk/".length)
.replace(/\.(tsx?|mts|cts|jsx?|mjs|cjs)$/, "")
.replace(/\/index$/, "");
sourceSeams.add(seam);
ensureArrayMap(sourceSeamUsage, seam).push(fileRel);
continue;
}
const externalRoot = getExternalPackageRoot(specifier);
if (!externalRoot) {
continue;
}
externalPackages.add(externalRoot);
if (!declaredPackages.has(externalRoot)) {
ensureArrayMap(missingExternalUsage, externalRoot).push(fileRel);
}
}
fileRecords.push({
file: fileRel,
publicSeams: [...publicSeams].toSorted(compareStrings),
sourceSeams: [...sourceSeams].toSorted(compareStrings),
externalPackages: [...externalPackages].toSorted(compareStrings),
});
}
}
fileRecords.sort((a, b) => a.file.localeCompare(b.file));
const overlapFiles = fileRecords
.filter((record) => record.publicSeams.length > 0 && record.sourceSeams.length > 0)
.map((record) => ({
file: record.file,
publicSeams: record.publicSeams,
sourceSeams: record.sourceSeams,
overlappingSeams: record.publicSeams.filter((seam) => record.sourceSeams.includes(seam)),
}))
.toSorted((a, b) => a.file.localeCompare(b.file));
const seamFamilies = [...new Set([...publicSeamUsage.keys(), ...sourceSeamUsage.keys()])]
.toSorted((a, b) => a.localeCompare(b))
.map((seam) => ({
seam,
publicImporterCount: new Set(publicSeamUsage.get(seam) ?? []).size,
sourceImporterCount: new Set(sourceSeamUsage.get(seam) ?? []).size,
publicImporters: [...new Set(publicSeamUsage.get(seam) ?? [])].toSorted(compareStrings),
sourceImporters: [...new Set(sourceSeamUsage.get(seam) ?? [])].toSorted(compareStrings),
}))
.filter((entry) => entry.publicImporterCount > 0 || entry.sourceImporterCount > 0);
const duplicatedSeamFamilies = seamFamilies.filter(
(entry) => entry.publicImporterCount > 0 && entry.sourceImporterCount > 0,
);
const missingPackages = [...missingExternalUsage.entries()]
.map(([packageName, files]) => {
const uniqueFiles = [...new Set(files)].toSorted(compareStrings);
const byTopLevel = {};
for (const file of uniqueFiles) {
const topLevel = file.split("/")[0] ?? file;
byTopLevel[topLevel] ??= [];
byTopLevel[topLevel].push(file);
}
const topLevelCounts = Object.entries(byTopLevel)
.map(([scope, scopeFiles]) => ({
scope,
fileCount: scopeFiles.length,
}))
.toSorted((a, b) => b.fileCount - a.fileCount || a.scope.localeCompare(b.scope));
return {
packageName,
importerCount: uniqueFiles.length,
importers: uniqueFiles,
topLevelCounts,
};
})
.toSorted(
(a, b) => b.importerCount - a.importerCount || a.packageName.localeCompare(b.packageName),
);
const summary = {
scannedFileCount: fileRecords.length,
filesUsingPublicPluginSdk: fileRecords.filter((record) => record.publicSeams.length > 0).length,
filesUsingSourcePluginSdk: fileRecords.filter((record) => record.sourceSeams.length > 0).length,
filesUsingBothPublicAndSourcePluginSdk: overlapFiles.length,
duplicatedSeamFamilyCount: duplicatedSeamFamilies.length,
missingExternalPackageCount: missingPackages.length,
};
const report = {
generatedAtUtc: new Date().toISOString(),
repoRoot: REPO_ROOT,
summary,
duplicatedSeamFamilies,
overlapFiles,
missingPackages,
};
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);