From 2f7875834874d8967a4badf10e261ef18fc1de4f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 00:48:28 -0700 Subject: [PATCH] feat: add plugin-root check command --- README.md | 101 +++++++++++++++++++++++++++++++++++++++++++---- src/cli.js | 35 ++++++++++++++-- src/config.js | 94 ++++++++++++++++++++++++++++++++++++++++++- src/index.js | 6 +++ src/inspector.js | 38 +++++++++++++++++- src/report.js | 20 +++++++++- test/cli.test.js | 54 +++++++++++++++++++++++++ 7 files changed, 331 insertions(+), 17 deletions(-) create mode 100644 test/cli.test.js diff --git a/README.md b/README.md index 7f028a1..5b59812 100644 --- a/README.md +++ b/README.md @@ -12,34 +12,119 @@ During development, use a local checkout or packed tarball: ```bash npm install --save-dev ../plugin-inspector +npx plugin-inspector check --no-openclaw ``` -Future package name: +After the package is published, plugin repos should install it as a dev +dependency and run it from the plugin root: ```bash npm install --save-dev @openclaw/plugin-inspector +npx @openclaw/plugin-inspector check ``` ## CLI -Inspect a crabpot-compatible fixture config: +Run the default plugin-root check from a plugin package directory: + +```bash +plugin-inspector check +``` + +That command reads the current directory as one plugin, inspects package +metadata, `openclaw.plugin.json`, source imports, `api.on(...)`, +`api.register*`, and writes: + +- `reports/plugin-inspector-report.json` +- `reports/plugin-inspector-report.md` +- `reports/plugin-inspector-issues.md` + +Use `--no-openclaw` when CI should not compare against a local OpenClaw +checkout: + +```bash +plugin-inspector check --no-openclaw +``` + +Use a simple plugin-root config when you want stable fixture metadata or +expected seams: + +```json +{ + "version": 1, + "plugin": { + "id": "weather", + "priority": "high", + "seams": ["dynamic-tool"], + "sourceRoot": "src", + "expect": { + "registrations": ["registerTool"] + } + }, + "openclaw": { + "defaultCheckoutPath": "../openclaw" + } +} +``` + +Then run: + +```bash +plugin-inspector check --config plugin-inspector.config.json +``` + +Fixture-set configs are still supported for crabpot-style compatibility suites: ```bash plugin-inspector report --config crabpot.config.json --out reports ``` -Fail if expected hooks, registrations, or manifest contracts are missing: - -```bash -plugin-inspector ci --config crabpot.config.json --out reports --check -``` - Capture a plugin entrypoint in an explicitly isolated execution lane: ```bash PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture ./dist/index.js ``` +### CI + +With a dev dependency: + +```json +{ + "scripts": { + "plugin:check": "plugin-inspector check --no-openclaw" + } +} +``` + +GitHub Actions: + +```yaml +name: plugin-inspector + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run plugin:check + - uses: actions/upload-artifact@v5 + if: always() + with: + name: plugin-inspector-reports + path: reports/plugin-inspector-* +``` + ## API ```js diff --git a/src/cli.js b/src/cli.js index e037b13..63208ac 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,23 +1,29 @@ #!/usr/bin/env node import { captureEntrypoint, + inspectCompatibilityFixtureSet, inspectFixtureSet, loadInspectorConfig, + loadPluginRootConfig, renderTextSummary, writeArtifacts, + writeCompatibilityReport, writeReport, } from "./index.js"; const args = process.argv.slice(2); -const command = args[0]; +const command = args[0]?.startsWith("-") ? "check" : (args[0] ?? "check"); +const commandArgs = args[0]?.startsWith("-") ? args : args.slice(1); try { - if (!command || command === "--help" || command === "-h") { + if (args.includes("--help") || args.includes("-h")) { printHelp(); + } else if (command === "check") { + await runCheck(commandArgs); } else if (command === "inspect" || command === "report" || command === "ci") { - await runReport(command, args.slice(1)); + await runReport(command, commandArgs); } else if (command === "capture") { - await runCapture(args.slice(1)); + await runCapture(commandArgs); } else { throw new Error(`unknown command: ${command}`); } @@ -26,6 +32,26 @@ try { process.exitCode = 1; } +async function runCheck(commandArgs) { + const configPath = readFlag(commandArgs, "--config"); + const outDir = readFlag(commandArgs, "--out") ?? "reports"; + const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw"); + const json = commandArgs.includes("--json"); + const config = configPath ? await loadInspectorConfig(configPath) : await loadPluginRootConfig(); + const report = await inspectCompatibilityFixtureSet(config, { openclawPath }); + await writeCompatibilityReport(report, { outDir }); + + if (json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(renderTextSummary(report)); + } + + if (report.status !== "pass") { + throw new Error(`plugin-inspector found ${report.summary.breakageCount} breakages`); + } +} + async function runReport(command, commandArgs) { const configPath = readFlag(commandArgs, "--config"); const outDir = readFlag(commandArgs, "--out") ?? "reports"; @@ -77,6 +103,7 @@ function printHelp() { console.log(`plugin-inspector Usage: + plugin-inspector check [--config ] [--out ] [--openclaw ] [--no-openclaw] [--json] plugin-inspector report --config [--out ] [--check] [--json] plugin-inspector inspect --config [--out ] [--check] [--json] plugin-inspector ci --config [--out ] diff --git a/src/config.js b/src/config.js index 8eaa43f..2ca926c 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,9 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; export const npmPackagePayloadDir = ".crabpot-package"; +export const defaultPluginRootConfigFiles = ["plugin-inspector.config.json", ".plugin-inspector.json"]; export async function loadInspectorConfig(configPath, options = {}) { if (!configPath) { @@ -10,9 +12,26 @@ export async function loadInspectorConfig(configPath, options = {}) { const resolvedPath = path.resolve(options.cwd ?? process.cwd(), configPath); const config = JSON.parse(await readFile(resolvedPath, "utf8")); const rootDir = path.resolve(options.cwd ?? process.cwd(), options.rootDir ?? path.dirname(resolvedPath)); - validateInspectorConfig(config); + const normalizedConfig = await normalizeInspectorConfig(config, { rootDir }); + validateInspectorConfig(normalizedConfig); return { - ...config, + ...normalizedConfig, + rootDir, + configPath: resolvedPath, + }; +} + +export async function loadPluginRootConfig(configPath = null, options = {}) { + const rootDir = path.resolve(options.cwd ?? process.cwd()); + const resolvedPath = configPath ? path.resolve(rootDir, configPath) : findPluginRootConfigPath(rootDir); + if (!resolvedPath && !existsSync(path.join(rootDir, "package.json")) && !existsSync(path.join(rootDir, "openclaw.plugin.json"))) { + throw new Error("run from a plugin root with package.json/openclaw.plugin.json, or pass --config"); + } + const config = resolvedPath ? JSON.parse(await readFile(resolvedPath, "utf8")) : { version: 1 }; + const normalizedConfig = await normalizePluginRootConfig(config, { rootDir }); + validateInspectorConfig(normalizedConfig); + return { + ...normalizedConfig, rootDir, configPath: resolvedPath, }; @@ -90,3 +109,74 @@ export function fixtureSourceRoot(config, fixture) { } return checkoutPath; } + +export async function normalizePluginRootConfig(config, options = {}) { + const rootDir = path.resolve(options.rootDir ?? process.cwd()); + const plugin = config.plugin ?? {}; + const packageJson = await readJsonIfExists(path.join(rootDir, "package.json")); + const pluginManifest = await readJsonIfExists(path.join(rootDir, "openclaw.plugin.json")); + const sourceRoot = plugin.sourceRoot ?? config.sourceRoot ?? "."; + const fixture = { + id: plugin.id ?? pluginManifest?.id ?? packageId(packageJson?.name) ?? "plugin", + name: plugin.name ?? pluginManifest?.name ?? packageJson?.name ?? "Plugin", + path: ".", + repo: "local", + priority: plugin.priority ?? config.priority ?? "high", + seams: plugin.seams ?? config.seams ?? inferPluginSeams(pluginManifest, packageJson), + why: plugin.why ?? config.why ?? "local OpenClaw plugin root", + expect: plugin.expect ?? config.expect, + }; + + if (sourceRoot !== ".") { + fixture.subdir = sourceRoot; + } + + return { + version: 1, + submoduleRoot: ".", + openclaw: config.openclaw, + fixtures: [fixture], + }; +} + +export async function normalizeInspectorConfig(config, options = {}) { + if (Array.isArray(config.fixtures)) { + return config; + } + return normalizePluginRootConfig(config, options); +} + +function findPluginRootConfigPath(rootDir) { + return defaultPluginRootConfigFiles.map((file) => path.join(rootDir, file)).find(existsSync) ?? null; +} + +async function readJsonIfExists(filePath) { + if (!existsSync(filePath)) { + return null; + } + return JSON.parse(await readFile(filePath, "utf8")); +} + +function packageId(packageName) { + if (!packageName) { + return null; + } + return packageName + .split("/") + .pop() + .replace(/^openclaw-/, "") + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); +} + +function inferPluginSeams(pluginManifest, packageJson) { + const contracts = Object.keys(pluginManifest?.contracts ?? {}); + if (contracts.includes("tools")) { + return ["dynamic-tool"]; + } + if (packageJson?.openclaw?.extensions || packageJson?.openclaw?.runtimeExtensions) { + return ["plugin-runtime"]; + } + return ["plugin-metadata"]; +} diff --git a/src/index.js b/src/index.js index 18aea28..364d2c1 100644 --- a/src/index.js +++ b/src/index.js @@ -106,14 +106,19 @@ export { } from "./openclaw-target.js"; export { captureEntrypoint, + inspectCompatibilityFixtureSet, inspectFixtureSet, inspectPlugin, inspectSourceText, } from "./inspector.js"; export { + defaultPluginRootConfigFiles, fixtureCheckoutPath, fixtureSourceRoot, loadInspectorConfig, + loadPluginRootConfig, + normalizeInspectorConfig, + normalizePluginRootConfig, validateInspectorConfig, } from "./config.js"; export { @@ -143,6 +148,7 @@ export { classifyCompatRecordCoverage, renderMarkdownReport, renderTextSummary, + writeCompatibilityReport, writeReport, } from "./report.js"; export { diff --git a/src/inspector.js b/src/inspector.js index 2d90615..6e96fac 100644 --- a/src/inspector.js +++ b/src/inspector.js @@ -4,9 +4,43 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { createCaptureApi } from "./capture-api.js"; import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js"; -import { buildReport } from "./report.js"; +import { buildCompatibilityFixtureReport } from "./fixture-summary.js"; +import { readOpenClawTargetSurface } from "./openclaw-target.js"; +import { buildCompatibilityReport, buildReport } from "./report.js"; export async function inspectFixtureSet(config, options = {}) { + const { inspections, failures } = await inspectConfiguredFixtures(config, options); + return buildReport({ config, inspections, failures, generatedAt: options.generatedAt }); +} + +export async function inspectCompatibilityFixtureSet(config, options = {}) { + const { inspections, failures } = await inspectConfiguredFixtures(config, options); + const targetOpenClaw = + options.targetOpenClaw ?? + (await readOpenClawTargetSurface({ + configuredPath: options.openclawPath, + manifest: config, + rootDir: config.rootDir, + })); + + return buildCompatibilityReport({ + config, + inspections, + failures, + generatedAt: options.generatedAt, + targetOpenClaw, + buildFixtureReport: ({ fixture, inspection }) => + buildCompatibilityFixtureReport({ + fixture, + inspection, + checkoutPath: fixtureCheckoutPath(config, fixture), + sourceRoot: fixtureSourceRoot(config, fixture), + rootDir: config.rootDir, + }), + }); +} + +async function inspectConfiguredFixtures(config, options = {}) { const inspections = []; const failures = []; @@ -27,7 +61,7 @@ export async function inspectFixtureSet(config, options = {}) { } } - return buildReport({ config, inspections, failures, generatedAt: options.generatedAt }); + return { inspections, failures }; } export async function inspectPlugin(fixture, options = {}) { diff --git a/src/report.js b/src/report.js index 14155e9..d3ca712 100644 --- a/src/report.js +++ b/src/report.js @@ -1,5 +1,6 @@ import path from "node:path"; -import { renderMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js"; +import { renderMarkdownTable, writeArtifacts, writeJsonMarkdownArtifacts } from "./artifacts.js"; +import { renderCompatibilityIssuesReport, renderCompatibilityMarkdownReport } from "./compatibility-report.js"; import { buildContractProbes } from "./contract-probes.js"; import { classifyCompatibilityFixture } from "./fixture-summary.js"; import { buildIssues, summarizeIssueClasses } from "./issues.js"; @@ -241,6 +242,23 @@ export async function writeReport(report, options = {}) { }); } +export async function writeCompatibilityReport(report, options = {}) { + const outDir = path.resolve(options.cwd ?? process.cwd(), options.outDir ?? "reports"); + const basename = options.basename ?? "plugin-inspector-report"; + const jsonPath = path.join(outDir, `${basename}.json`); + const markdownPath = path.join(outDir, `${basename}.md`); + const issuesPath = path.join(outDir, options.issuesBasename ?? "plugin-inspector-issues.md"); + + return writeArtifacts( + [ + { name: "jsonPath", path: jsonPath, json: report }, + { name: "markdownPath", path: markdownPath, markdown: renderCompatibilityMarkdownReport(report) }, + { name: "issuesPath", path: issuesPath, markdown: renderCompatibilityIssuesReport(report) }, + ], + { check: options.check }, + ); +} + export function renderTextSummary(report) { return [ `Status: ${report.status.toUpperCase()}`, diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..60ad751 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { test } from "node:test"; + +const execFileAsync = promisify(execFile); + +test("check command runs from a plugin root without fixture config", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-cli-root-")); + await mkdir(path.join(rootDir, "src"), { recursive: true }); + await writeFile( + path.join(rootDir, "package.json"), + `${JSON.stringify( + { + name: "@example/openclaw-weather", + version: "1.0.0", + type: "module", + openclaw: { + extensions: ["src/index.js"], + compat: { pluginApi: "^1.0.0" }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeFile( + path.join(rootDir, "openclaw.plugin.json"), + `${JSON.stringify({ id: "weather", name: "Weather", version: "1.0.0", contracts: { tools: {} } }, null, 2)}\n`, + "utf8", + ); + await writeFile( + path.join(rootDir, "src", "index.js"), + 'import { definePluginEntry } from "openclaw/plugin-sdk";\nexport default definePluginEntry((api) => api.registerTool({ name: "weather" }));\n', + "utf8", + ); + + const cliPath = path.resolve("src/cli.js"); + const { stdout } = await execFileAsync(process.execPath, [cliPath, "check", "--out", "reports", "--no-openclaw"], { + cwd: rootDir, + }); + const report = JSON.parse(await readFile(path.join(rootDir, "reports", "plugin-inspector-report.json"), "utf8")); + const issues = await readFile(path.join(rootDir, "reports", "plugin-inspector-issues.md"), "utf8"); + + assert.match(stdout, /Status: PASS/); + assert.equal(report.targetOpenClaw.status, "disabled"); + assert.equal(report.fixtures[0].id, "weather"); + assert.ok(report.fixtures[0].package.openclaw.entrypoints.some((entrypoint) => entrypoint.exists)); + assert.match(issues, /# OpenClaw Plugin Issue Findings/); +});