#!/usr/bin/env node // Verifies plugin SDK subpath exports and generated entrypoint metadata. import { readFileSync } from "node:fs"; import path from "node:path"; import ts from "typescript"; import { normalizeRepoPath, visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, resolveSourceRoots, toLine, } from "./lib/ts-guard-utils.mts"; const repoRoot = resolveRepoRoot(import.meta.url); const scanRoots = resolveSourceRoots(repoRoot, [ "src", "packages", "extensions", "scripts", "test", ]); type PluginSdkViolation = { file: string; kind: string; line: number; reason: string; specifier: string; subpath: string; }; type ModuleSpecifierVisit = { kind: string; node: ts.Node; specifier: string; specifierNode: ts.Node; }; function readPackageExports(): Set { const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { exports?: Record; }; return new Set( Object.keys(packageJson.exports ?? {}) .filter((key) => key.startsWith("./plugin-sdk/")) .map((key) => key.slice("./plugin-sdk/".length)), ); } function readEntrypoints(): Set { const entrypoints = JSON.parse( readFileSync(path.join(repoRoot, "scripts/lib/plugin-sdk-entrypoints.json"), "utf8"), ) as unknown[]; return new Set( entrypoints.filter((entry): entry is string => typeof entry === "string" && entry !== "index"), ); } function readPrivateLocalOnlySubpaths(): Set { const subpaths = JSON.parse( readFileSync( path.join(repoRoot, "scripts/lib/plugin-sdk-private-local-only-subpaths.json"), "utf8", ), ) as unknown[]; return new Set( subpaths.filter((entry): entry is string => typeof entry === "string" && !entry.includes("/")), ); } function parsePluginSdkSubpath(specifier: string): string | null { return specifier.match(/^@?openclaw\/plugin-sdk\/(.+)$/u)?.[1] ?? null; } function isGeneratedBuildArtifact(filePath: string): boolean { return normalizeRepoPath(repoRoot, filePath).split("/").includes("dist"); } function isRuntimeModuleReference(node: ts.Node): boolean { // With verbatimModuleSyntax, inline `type` specifiers emit an empty import/export and still // resolve the module. Only declaration-level `import type` and `export type` are erased. if (ts.isImportDeclaration(node)) { return !node.importClause?.isTypeOnly; } if (ts.isExportDeclaration(node)) { return !node.isTypeOnly; } if (ts.isImportTypeNode(node)) { return false; } if (ts.isImportEqualsDeclaration(node)) { return !node.isTypeOnly; } return true; } function compareEntries(left: PluginSdkViolation, right: PluginSdkViolation): number { return ( left.file.localeCompare(right.file) || left.line - right.line || left.kind.localeCompare(right.kind) || left.specifier.localeCompare(right.specifier) || left.subpath.localeCompare(right.subpath) ); } async function collectViolations(): Promise { const entrypoints = readEntrypoints(); const exports = readPackageExports(); const privateLocalOnlySubpaths = readPrivateLocalOnlySubpaths(); // Workspace packages resolve private facades through root TS paths and bundle them into dist; // live jiti source stages inject the same private map. Core src callers must stay relative. const coreRuntimeFiles = new Set( ( await collectTypeScriptFilesFromRoots(resolveSourceRoots(repoRoot, ["src"]), { includeTests: false, extraTestSuffixes: [".test-support.ts", ".test-loader.ts", ".test-fixtures.ts"], }) ).filter((filePath) => !isGeneratedBuildArtifact(filePath)), ); const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true })) .filter((filePath) => !isGeneratedBuildArtifact(filePath)) .toSorted((left, right) => normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)), ); const violations: PluginSdkViolation[] = []; for (const filePath of files) { const sourceText = readFileSync(filePath, "utf8"); const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); function push(kind: string, node: ts.Node, specifierNode: ts.Node, specifier: string): void { const subpath = parsePluginSdkSubpath(specifier); if (!subpath) { return; } if (privateLocalOnlySubpaths.has(subpath)) { const repoPath = normalizeRepoPath(repoRoot, filePath); if (coreRuntimeFiles.has(filePath) && isRuntimeModuleReference(node)) { violations.push({ file: repoPath, line: toLine(sourceFile, specifierNode), kind, specifier, subpath, reason: "private runtime helper used by core must use a relative import", }); } return; } const missingFrom: string[] = []; if (!entrypoints.has(subpath)) { missingFrom.push("scripts/lib/plugin-sdk-entrypoints.json"); } if (!exports.has(subpath)) { missingFrom.push("package.json exports"); } if (missingFrom.length === 0) { return; } violations.push({ file: normalizeRepoPath(repoRoot, filePath), line: toLine(sourceFile, specifierNode), kind, specifier, subpath, reason: `missing from ${missingFrom.join(" and ")}`, }); } visitModuleSpecifiers( ts, sourceFile, ({ kind, node, specifier, specifierNode }: ModuleSpecifierVisit) => { push(kind, node, specifierNode, specifier); }, { includeCommonJs: true, includeImportTypes: true }, ); } return violations.toSorted(compareEntries); } async function main(): Promise { const violations = await collectViolations(); if (violations.length === 0) { console.log("OK: all referenced openclaw/plugin-sdk/ imports are exported."); return; } console.error( "Rule: every referenced openclaw/plugin-sdk/ must be public or use its required private boundary.", ); for (const violation of violations) { console.error( `- ${violation.file}:${violation.line} [${violation.kind}] ${violation.specifier}: ${violation.reason}`, ); } process.exit(1); } main().catch((error: unknown) => { console.error(error); process.exit(1); });