299 lines
9.2 KiB
JavaScript
299 lines
9.2 KiB
JavaScript
#!/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`);
|