diff --git a/scripts/plugin-boundary-report.ts b/scripts/plugin-boundary-report.ts index 55110b76910..5e9c95b378f 100644 --- a/scripts/plugin-boundary-report.ts +++ b/scripts/plugin-boundary-report.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { pluginSdkEntrypoints, publicPluginOwnedSdkEntrypoints, @@ -48,6 +49,12 @@ type CompatDebtRecord = { eligibleForRemoval: boolean; }; +type WorkspaceTextFile = { + file: string; + relativeFile: string; + source: string; +}; + type ReservedSdkImport = { file: string; specifier: string; @@ -114,6 +121,12 @@ type BoundaryReportSummary = { }; }; +export type PluginBoundaryReportResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + function collectTextFiles(dir: string): string[] { const files: string[] = []; if (!existsSync(dir)) { @@ -145,6 +158,14 @@ function repoRelative(file: string): string { return relative(REPO_ROOT, file).replaceAll("\\", "/"); } +function collectWorkspaceTextFileSources(): WorkspaceTextFile[] { + return collectWorkspaceTextFiles().map((file) => ({ + file, + relativeFile: repoRelative(file), + source: readFileSync(file, "utf8"), + })); +} + function isDocsFile(file: string): boolean { return file.startsWith("docs/") || file === "README.md"; } @@ -243,15 +264,13 @@ function extractCompatTokens(record: PluginCompatRecord): string[] { return [...tokens].toSorted(); } -function collectReferenceFiles(files: readonly string[], tokens: readonly string[]) { +function collectReferenceFiles(files: readonly WorkspaceTextFile[], tokens: readonly string[]) { const codeReferenceFiles = new Set(); const docReferenceFiles = new Set(); - for (const file of files) { - const relativeFile = repoRelative(file); + for (const { relativeFile, source } of files) { if (relativeFile === "src/plugins/compat/registry.ts") { continue; } - const source = readFileSync(file, "utf8"); if (!tokens.some((token) => source.includes(token))) { continue; } @@ -267,11 +286,18 @@ function collectReferenceFiles(files: readonly string[], tokens: readonly string }; } -function collectCompatDebt(files: readonly string[], today = new Date()): CompatDebtRecord[] { +function collectCompatDebt( + files: readonly WorkspaceTextFile[], + today = new Date(), + options: { includeReferenceFiles?: boolean } = {}, +): CompatDebtRecord[] { return PLUGIN_COMPAT_RECORDS.filter((record) => record.status === "deprecated") .map((record) => { const tokens = extractCompatTokens(record); - const references = collectReferenceFiles(files, tokens); + const references = + options.includeReferenceFiles === false + ? { codeReferenceFiles: [], docReferenceFiles: [] } + : collectReferenceFiles(files, tokens); const eligibleForRemoval = record.removeAfter ? new Date(`${record.removeAfter}T00:00:00Z`) <= today : false; @@ -297,13 +323,11 @@ function collectCompatDebt(files: readonly string[], today = new Date()): Compat ); } -function collectReservedSdkImports(files: readonly string[]): ReservedSdkImport[] { +function collectReservedSdkImports(files: readonly WorkspaceTextFile[]): ReservedSdkImport[] { const reserved = new Set(reservedBundledPluginSdkEntrypoints); const pluginIds = collectBundledPluginIds(); const imports: ReservedSdkImport[] = []; - for (const file of files) { - const relativeFile = repoRelative(file); - const source = readFileSync(file, "utf8"); + for (const { relativeFile, source } of files) { for (const match of source.matchAll(PLUGIN_SDK_SPECIFIER_PATTERN)) { const specifier = match[1]; const subpath = match[2]; @@ -325,18 +349,18 @@ function collectReservedSdkImports(files: readonly string[]): ReservedSdkImport[ ); } -function collectMemoryHostBoundary(files: readonly string[]): BoundaryReport["memoryHostSdk"] { +function collectMemoryHostBoundary( + files: readonly WorkspaceTextFile[], +): BoundaryReport["memoryHostSdk"] { const packageJson = JSON.parse( readFileSync(resolve(REPO_ROOT, "packages/memory-host-sdk/package.json"), "utf8"), ) as { private?: boolean; exports?: Record }; const sourceBridgeFiles: string[] = []; const packageCoreReferenceFiles = new Set(); - for (const file of files) { - const relativeFile = repoRelative(file); + for (const { relativeFile, source } of files) { if (!relativeFile.startsWith("packages/memory-host-sdk/src/")) { continue; } - const source = readFileSync(file, "utf8"); if (source.includes("src/memory-host-sdk/")) { sourceBridgeFiles.push(relativeFile); } @@ -419,12 +443,12 @@ function buildSummary(report: BoundaryReport, owner?: string): BoundaryReportSum }; } -function buildReport(options: Pick = {}): BoundaryReport { - const files = collectWorkspaceTextFiles(); +function buildReport(options: Pick = {}): BoundaryReport { + const files = collectWorkspaceTextFileSources(); const pluginIds = collectBundledPluginIds(); - const compatRecords = collectCompatDebt(files).filter((record) => - matchesOwner(options.owner, record.owner), - ); + const compatRecords = collectCompatDebt(files, new Date(), { + includeReferenceFiles: !options.summary, + }).filter((record) => matchesOwner(options.owner, record.owner)); const reservedImports = collectReservedSdkImports(files).filter( (entry) => matchesOwner(options.owner, entry.owner) || matchesOwner(options.owner, entry.consumerOwner), @@ -539,35 +563,51 @@ function collectFailures(report: BoundaryReport, options: CliOptions): string[] return failures; } -let options: CliOptions; -try { - options = parseArgs(process.argv.slice(2)); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n\n${renderHelp()}\n`); - process.exitCode = 2; - process.exit(); +export function createPluginBoundaryReport(args: readonly string[]): PluginBoundaryReportResult { + const options = parseArgs(args); + if (options.help) { + return { + stdout: `${renderHelp()}\n`, + stderr: "", + exitCode: 0, + }; + } + + const report = buildReport(options); + const summary = buildSummary(report, options.owner); + const body = options.json + ? JSON.stringify(options.summary ? summary : report, null, 2) + : options.summary + ? renderSummaryText(summary) + : renderText(report, options.owner); + const failures = collectFailures(report, options); + return { + stdout: `${body}\n`, + stderr: + failures.length > 0 + ? `${failures.map((failure) => `plugin-boundary-report: ${failure}`).join("\n")}\n` + : "", + exitCode: failures.length > 0 ? 1 : 0, + }; } -if (options.help) { - process.stdout.write(`${renderHelp()}\n`); - process.exit(); +function runPluginBoundaryReportCli(args: readonly string[]): void { + let result: PluginBoundaryReportResult; + try { + result = createPluginBoundaryReport(args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n\n${renderHelp()}\n`); + process.exitCode = 2; + return; + } + process.stdout.write(result.stdout); + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.exitCode = result.exitCode; } -const report = buildReport(options); -const summary = buildSummary(report, options.owner); -if (options.json) { - process.stdout.write(`${JSON.stringify(options.summary ? summary : report, null, 2)}\n`); -} else if (options.summary) { - process.stdout.write(`${renderSummaryText(summary)}\n`); -} else { - process.stdout.write(`${renderText(report, options.owner)}\n`); -} - -const failures = collectFailures(report, options); -if (failures.length > 0) { - process.stderr.write( - `${failures.map((failure) => `plugin-boundary-report: ${failure}`).join("\n")}\n`, - ); - process.exitCode = 1; +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runPluginBoundaryReportCli(process.argv.slice(2)); } diff --git a/src/test-utils/openclaw-test-state.test.ts b/src/test-utils/openclaw-test-state.test.ts index a450467b6c8..b10575dbd02 100644 --- a/src/test-utils/openclaw-test-state.test.ts +++ b/src/test-utils/openclaw-test-state.test.ts @@ -15,19 +15,21 @@ describe("openclaw test state", () => { scenario: "minimal", }); - expect(state.home).toBe(path.join(state.root, "home")); - expect(state.stateDir).toBe(path.join(state.home, ".openclaw")); - expect(state.configPath).toBe(path.join(state.stateDir, "openclaw.json")); - expect(state.workspaceDir).toBe(path.join(state.home, "workspace")); - expect(state.env.HOME).toBe(state.home); - expect(state.env.OPENCLAW_HOME).toBe(state.home); - expect(state.env.OPENCLAW_STATE_DIR).toBe(state.stateDir); - expect(state.env.OPENCLAW_CONFIG_PATH).toBe(state.configPath); - expect(process.env.HOME).toBe(state.home); - expect(process.env.OPENCLAW_HOME).toBe(state.home); - expect(JSON.parse(await fs.readFile(state.configPath, "utf8"))).toEqual({}); - - await state.cleanup(); + try { + expect(state.home).toBe(path.join(state.root, "home")); + expect(state.stateDir).toBe(path.join(state.home, ".openclaw")); + expect(state.configPath).toBe(path.join(state.stateDir, "openclaw.json")); + expect(state.workspaceDir).toBe(path.join(state.home, "workspace")); + expect(state.env.HOME).toBe(state.home); + expect(state.env.OPENCLAW_HOME).toBe(state.home); + expect(state.env.OPENCLAW_STATE_DIR).toBe(state.stateDir); + expect(state.env.OPENCLAW_CONFIG_PATH).toBe(state.configPath); + expect(process.env.HOME).toBe(state.home); + expect(process.env.OPENCLAW_HOME).toBe(state.home); + expect(JSON.parse(await fs.readFile(state.configPath, "utf8"))).toEqual({}); + } finally { + await state.cleanup(); + } expect(process.env.HOME).toBe(previousHome); expect(process.env.OPENCLAW_HOME).toBe(previousOpenClawHome); diff --git a/test/scripts/plugin-boundary-report.test.ts b/test/scripts/plugin-boundary-report.test.ts index 528c9a6e6cf..2907d589b69 100644 --- a/test/scripts/plugin-boundary-report.test.ts +++ b/test/scripts/plugin-boundary-report.test.ts @@ -1,30 +1,15 @@ -import { execFileSync } from "node:child_process"; -import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; - -const REPO_ROOT = resolve(import.meta.dirname, "../.."); - -function runBoundaryReport(...args: string[]): string { - return execFileSync( - process.execPath, - ["--import", "tsx", "scripts/plugin-boundary-report.ts", ...args], - { - cwd: REPO_ROOT, - encoding: "utf8", - maxBuffer: 1024 * 1024, - }, - ); -} +import { createPluginBoundaryReport } from "../../scripts/plugin-boundary-report.js"; describe("plugin-boundary-report", () => { it("emits compact CI-safe summary JSON", () => { - const output = runBoundaryReport( + const result = createPluginBoundaryReport([ "--summary", "--json", "--fail-on-cross-owner", "--fail-on-unclassified-unused-reserved", - ); - const summary = JSON.parse(output) as { + ]); + const summary = JSON.parse(result.stdout) as { pluginSdk?: { crossOwnerReservedImportCount?: unknown; unusedReservedCount?: unknown; @@ -34,6 +19,7 @@ describe("plugin-boundary-report", () => { }; }; + expect(result).toMatchObject({ exitCode: 0, stderr: "" }); expect(summary.pluginSdk?.crossOwnerReservedImportCount).toBe(0); expect(summary.pluginSdk?.unusedReservedCount).toBe(0); expect(["private-core-bridge", "private-package-core-integrated"]).toContain( diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 464760274d8..19d1e78c7f6 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -198,6 +198,7 @@ export const forcedUnitFastTestFiles = [ "src/terminal/table.test.ts", "src/test-helpers/state-dir-env.test.ts", "src/test-utils/env.test.ts", + "src/test-utils/openclaw-test-state.test.ts", "src/test-utils/temp-home.test.ts", "src/utils.test.ts", "src/version.test.ts",