Tools: classify optional bundled clusters

This commit is contained in:
Vincent Koc 2026-03-18 09:22:45 -07:00
parent 3d8afb96bd
commit d8008a9a67
2 changed files with 169 additions and 0 deletions

View File

@ -4,6 +4,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcRoot = path.join(repoRoot, "src");
@ -78,6 +79,18 @@ function normalizePluginSdkFamily(resolvedPath) {
return relative.replace(/\.(m|c)?[jt]sx?$/, "");
}
function resolveOptionalClusterFromPath(resolvedPath) {
if (resolvedPath.startsWith("extensions/")) {
const cluster = resolvedPath.split("/")[1];
return optionalBundledClusterSet.has(cluster) ? cluster : null;
}
if (resolvedPath.startsWith("src/plugin-sdk/")) {
const cluster = normalizePluginSdkFamily(resolvedPath).split("/")[0];
return optionalBundledClusterSet.has(cluster) ? cluster : null;
}
return null;
}
function compareImports(left, right) {
return (
left.family.localeCompare(right.family) ||
@ -152,6 +165,79 @@ async function collectCorePluginSdkImports() {
return inventory.toSorted(compareImports);
}
function collectOptionalClusterStaticImports(filePath, sourceFile) {
const entries = [];
function push(kind, specifierNode, specifier) {
if (!specifier.startsWith(".")) {
return;
}
const resolvedPath = resolveRelativeSpecifier(specifier, filePath);
if (!resolvedPath) {
return;
}
const cluster = resolveOptionalClusterFromPath(resolvedPath);
if (!cluster) {
return;
}
entries.push({
cluster,
file: normalizePath(filePath),
kind,
line: toLine(sourceFile, specifierNode),
resolvedPath,
specifier,
});
}
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);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return entries;
}
async function collectOptionalClusterStaticLeaks() {
const files = await walkCodeFiles(srcRoot);
const inventory = [];
for (const filePath of files) {
const relativePath = normalizePath(filePath);
if (relativePath.startsWith("src/plugin-sdk/")) {
continue;
}
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(...collectOptionalClusterStaticImports(filePath, sourceFile));
}
return inventory.toSorted((left, right) => {
return (
left.cluster.localeCompare(right.cluster) ||
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.specifier.localeCompare(right.specifier)
);
});
}
function buildDuplicatedSeamFamilies(inventory) {
const grouped = new Map();
for (const entry of inventory) {
@ -207,6 +293,30 @@ function buildOverlapFiles(inventory) {
});
}
function buildOptionalClusterStaticLeaks(inventory) {
const grouped = new Map();
for (const entry of inventory) {
const bucket = grouped.get(entry.cluster) ?? [];
bucket.push(entry);
grouped.set(entry.cluster, bucket);
}
return Object.fromEntries(
[...grouped.entries()]
.map(([cluster, entries]) => [
cluster,
{
count: entries.length,
files: [...new Set(entries.map((entry) => entry.file))].toSorted(compareStrings),
imports: entries,
},
])
.toSorted((left, right) => {
return right[1].count - left[1].count || left[0].localeCompare(right[0]);
}),
);
}
function packageClusterMeta(relativePackagePath) {
if (relativePackagePath === "ui/package.json") {
return {
@ -227,6 +337,35 @@ function packageClusterMeta(relativePackagePath) {
};
}
function classifyMissingPackageCluster(params) {
if (optionalBundledClusterSet.has(params.cluster)) {
if (params.cluster === "ui") {
return {
decision: "optional",
reason:
"Private UI workspace. Repo-wide CLI/plugin CI should not require UI-only packages.",
};
}
if (params.pluginSdkEntries.length > 0) {
return {
decision: "optional",
reason:
"Public plugin-sdk entry exists, but repo-wide default check/build should isolate this optional cluster from the static graph.",
};
}
return {
decision: "optional",
reason:
"Workspace package is intentionally not mirrored into the root dependency set by default CI policy.",
};
}
return {
decision: "required",
reason:
"Cluster is statically visible to repo-wide check/build and has not been classified optional.",
};
}
async function buildMissingPackages() {
const rootPackage = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"));
const rootDeps = new Set([
@ -264,15 +403,27 @@ async function buildMissingPackages() {
continue;
}
const meta = packageClusterMeta(relativePackagePath);
const rootDependencyMirrorAllowlist = (
pkg.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []
).toSorted(compareStrings);
const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted(
compareStrings,
);
const classification = classifyMissingPackageCluster({
cluster: meta.cluster,
pluginSdkEntries,
});
output.push({
cluster: meta.cluster,
decision: classification.decision,
decisionReason: classification.reason,
packageName: pkg.name ?? meta.packageName,
packagePath: relativePackagePath,
npmSpec: pkg.openclaw?.install?.npmSpec ?? null,
private: pkg.private === true,
rootDependencyMirrorAllowlist,
mirrorAllowlistMatchesMissing:
missing.join("\n") === rootDependencyMirrorAllowlist.join("\n"),
pluginSdkReachability:
pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined,
missing,
@ -286,9 +437,11 @@ async function buildMissingPackages() {
await collectWorkspacePackagePaths();
const inventory = await collectCorePluginSdkImports();
const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks();
const result = {
duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory),
overlapFiles: buildOverlapFiles(inventory),
optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks),
missingPackages: await buildMissingPackages(),
};

View File

@ -0,0 +1,16 @@
export const optionalBundledClusters = [
"acpx",
"diagnostics-otel",
"diffs",
"googlechat",
"matrix",
"memory-lancedb",
"msteams",
"nostr",
"tlon",
"twitch",
"ui",
"zalouser",
];
export const optionalBundledClusterSet = new Set(optionalBundledClusters);