feat: add plugin-root check command

This commit is contained in:
Vincent Koc 2026-04-27 00:48:28 -07:00
parent 4630939c5c
commit 2f78758348
No known key found for this signature in database
7 changed files with 331 additions and 17 deletions

101
README.md
View File

@ -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

View File

@ -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 <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--json]
plugin-inspector report --config <path> [--out <dir>] [--check] [--json]
plugin-inspector inspect --config <path> [--out <dir>] [--check] [--json]
plugin-inspector ci --config <path> [--out <dir>]

View File

@ -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"];
}

View File

@ -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 {

View File

@ -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 = {}) {

View File

@ -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()}`,

54
test/cli.test.js Normal file
View File

@ -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/);
});