mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 14:00:51 +00:00
Add plugin-sdk seam audit script
This commit is contained in:
298
scripts/audit-plugin-sdk-seams.mjs
Normal file
298
scripts/audit-plugin-sdk-seams.mjs
Normal 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`);
|
||||
Reference in New Issue
Block a user