Compare commits

...

8 Commits
v0.3.6 ... main

Author SHA1 Message Date
Vincent Koc
8899fc796c
fix(reports): accept packaged runtime entrypoints
Some checks failed
Check / Node 22 (push) Has been cancelled
2026-05-06 02:46:10 -07:00
Vincent Koc
feefb4ee23
fix(contract): accept compat-gap coverage records
Some checks failed
Check / Node 22 (push) Has been cancelled
Treat compat-gap issues as reconciliation evidence for their own compatibility records.
2026-05-05 00:50:58 -07:00
Vincent Koc
f642fb5c9f
fix(reports): quiet resolved package entrypoint P0s
Fix compatibility report classification so built runtime package entrypoints satisfy source-form OpenClaw metadata, SDK export alias misses collapse into a single compat-gap row, and P0 live issues are not repeated under the general live section.
2026-05-05 00:34:18 -07:00
Vincent Koc
68e10e0aaa
chore(release): prepare 0.3.10
Some checks failed
Check / Node 22 (push) Has been cancelled
2026-05-03 01:13:33 -07:00
Vincent Koc
12005b4658
fix(capture): accept valid mocked capture output 2026-05-03 01:09:52 -07:00
Vincent Koc
4956ad1fbc
fix(capture): follow bundled channel exports 2026-05-03 00:59:42 -07:00
Vincent Koc
a58e0785d5
fix(capture): synthesize manifest plugin config 2026-05-03 00:11:24 -07:00
Vincent Koc
9f45c8aeb6
fix(report): downgrade registration capture gaps 2026-05-02 22:43:55 -07:00
22 changed files with 645 additions and 41 deletions

View File

@ -2,6 +2,35 @@
## Unreleased
### Fixed
- Stop classifying package source entrypoints as missing when the published package provides built runtime entrypoints, and collapse SDK alias findings into a single compat-gap row.
- Treat compat-gap issues as reconciled contract coverage for their own compatibility record.
## 0.3.10 - 2026-05-03
### Fixed
- Accept valid mocked capture output when plugin code leaves `process.exitCode` dirty.
## 0.3.9 - 2026-05-03
### Fixed
- Follow bundled channel `loadBundledEntryExportSync` registration exports during mocked runtime capture.
## 0.3.8 - 2026-05-03
### Fixed
- Synthesize manifest config for isolated runtime capture so configured hooks can be observed without credentials.
## 0.3.7 - 2026-05-03
### Changed
- Downgrade `registration-capture-gap` to advisory severity so missing capture evidence no longer reports as a P1 plugin contract risk.
## 0.3.6 - 2026-05-03
### Changed

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/plugin-inspector",
"version": "0.3.6",
"version": "0.3.10",
"private": false,
"description": "Offline compatibility inspector for OpenClaw plugins.",
"type": "module",

View File

@ -148,6 +148,7 @@ export function createCaptureContext(options = {}) {
config: options.config ?? {},
logger: options.logger ?? console,
pluginConfig: options.pluginConfig ?? {},
resolvePath: options.resolvePath ?? ((value) => value),
runtime: options.runtime ?? createRuntimeContext(options),
secrets: options.secrets ?? createSecretContext(options),
store: options.store ?? createStoreContext(options),

126
src/capture-config.js Normal file
View File

@ -0,0 +1,126 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
export async function captureApiOptionsForPlugin(apiOptions = {}, options = {}) {
if (apiOptions.pluginConfig !== undefined || !options.pluginRoot) {
return apiOptions;
}
const pluginConfig = await readSamplePluginConfig(options.pluginRoot);
if (pluginConfig === undefined) {
return apiOptions;
}
return {
...apiOptions,
pluginConfig,
};
}
async function readSamplePluginConfig(pluginRoot) {
const manifestPath = path.join(pluginRoot, "openclaw.plugin.json");
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch {
return undefined;
}
const sample = sampleJsonSchema(manifest.configSchema, { key: "config" });
return isPlainObject(sample) && Object.keys(sample).length > 0 ? sample : undefined;
}
function sampleJsonSchema(schema, context = {}) {
if (!isPlainObject(schema)) {
return undefined;
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
return schema.enum[0];
}
if (Object.prototype.hasOwnProperty.call(schema, "const")) {
return schema.const;
}
if (Object.prototype.hasOwnProperty.call(schema, "default")) {
return schema.default;
}
const type = Array.isArray(schema.type) ? schema.type.find((item) => item !== "null") : schema.type;
if (type === "object" || schema.properties) {
return sampleObjectSchema(schema);
}
if (type === "array") {
return [];
}
if (type === "boolean") {
return false;
}
if (type === "number" || type === "integer") {
return typeof schema.minimum === "number" ? schema.minimum : 1;
}
if (type === "string" || !type) {
return sampleString(context.key);
}
return undefined;
}
function sampleObjectSchema(schema) {
const properties = isPlainObject(schema.properties) ? schema.properties : {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const output = {};
for (const key of Object.keys(properties)) {
if (required.has(key)) {
const value = sampleJsonSchema(properties[key], { key });
if (value !== undefined) {
output[key] = value;
}
}
}
for (const key of Object.keys(properties)) {
if (properties[key]?.type === "boolean") {
output[key] = false;
}
}
if (Object.keys(output).length === 0 && Number(schema.minProperties ?? 0) > 0) {
const key = preferredSamplePropertyKey(properties);
if (key) {
const value = sampleJsonSchema(properties[key], { key });
if (value !== undefined) {
output[key] = value;
}
}
}
return output;
}
function preferredSamplePropertyKey(properties) {
for (const key of ["provider", "model", "apiKey", "id", "name", ...Object.keys(properties)]) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
return key;
}
}
return null;
}
function sampleString(key = "") {
if (key === "provider") {
return "openai";
}
if (key === "model") {
return "text-embedding-3-small";
}
if (key === "apiKey") {
return "fixture-api-key";
}
if (key === "dbPath") {
return ".plugin-inspector/state/lancedb";
}
return "fixture";
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View File

@ -58,9 +58,9 @@ export function renderCompatibilityMarkdownReport(report, options = {}) {
options,
),
"",
"## Live Issues",
"## Other Live Issues",
"",
issuesTable(report.issues.filter((issue) => issue.issueClass === "live-issue"), options),
issuesTable(report.issues.filter((issue) => issue.issueClass === "live-issue" && issue.severity !== "P0"), options),
"",
"## Compat Gaps",
"",
@ -183,9 +183,9 @@ export function renderCompatibilityIssuesReport(report, options = {}) {
options,
),
"",
"## Live Issues",
"## Other Live Issues",
"",
issuesTable(report.issues.filter((issue) => issue.issueClass === "live-issue"), options),
issuesTable(report.issues.filter((issue) => issue.issueClass === "live-issue" && issue.severity !== "P0"), options),
"",
"## Compat Gaps",
"",

View File

@ -157,10 +157,15 @@ function requireCompatRecordReconciliation(report, errors) {
.filter((finding) => finding.code === "missing-compat-record")
.map((finding) => `${finding.fixture}:${finding.compatRecord}`),
);
const compatGapRecords = new Set(
report.issues
.filter((issue) => issue.issueClass === "compat-gap" && issue.compatRecord)
.map((issue) => `${issue.fixture}:${issue.compatRecord}`),
);
for (const finding of [...report.warnings, ...report.suggestions].filter((item) => item.compatRecord)) {
const key = `${finding.fixture}:${finding.compatRecord}`;
if (!presentRecords.has(key) && !missingRecords.has(key)) {
if (!presentRecords.has(key) && !missingRecords.has(key) && !compatGapRecords.has(key)) {
errors.push(`${finding.fixture}: compat record ${finding.compatRecord} was not reconciled`);
}
}

View File

@ -175,7 +175,6 @@ export function probePriority(code, fixturePriority) {
"before-tool-call-probe",
"conversation-access-hook",
"missing-compat-record",
"registration-capture-gap",
"sdk-export-missing",
].includes(code)
) {

View File

@ -340,9 +340,12 @@ export function classifyPackageContracts({ fixture, inspection, fixtureReport })
});
}
const missingEntrypoints = packageSummary.openclaw?.entrypoints.filter((entrypoint) => !entrypoint.exists) ?? [];
const entrypoints = packageSummary.openclaw?.entrypoints ?? [];
const missingEntrypoints = entrypoints.filter((entrypoint) => !entrypoint.exists);
const buildEntrypoints = missingEntrypoints.filter((entrypoint) => entrypoint.requiresBuild);
const plainMissingEntrypoints = missingEntrypoints.filter((entrypoint) => !entrypoint.requiresBuild);
const plainMissingEntrypoints = missingEntrypoints.filter(
(entrypoint) => !entrypoint.requiresBuild && !hasUsablePackageRuntimeEntrypoint(entrypoint, packageSummary, entrypoints),
);
if (buildEntrypoints.length > 0) {
suggestions.push({
@ -920,6 +923,46 @@ function collectOpenClawEntrypoints(packageDir, openclaw, options) {
});
}
function hasUsablePackageRuntimeEntrypoint(entrypoint, packageSummary, entrypoints) {
if (!isSourceEntrypoint(entrypoint.specifier)) {
return false;
}
const runtimeBuildSpecifier = runtimeBuildSpecifierFor(entrypoint.specifier);
if (
entrypoints.some(
(candidate) =>
candidate.exists &&
candidate.requiresBuild &&
normalizeEntrypointSpecifier(candidate.specifier) === normalizeEntrypointSpecifier(runtimeBuildSpecifier),
)
) {
return true;
}
if (entrypoint.kind === "extension" && entrypoints.some((candidate) => candidate.kind === "runtimeExtension" && candidate.exists)) {
return true;
}
const packageDir = path.dirname(packageSummary.path);
return existsSync(path.resolve(packageDir, runtimeBuildSpecifier));
}
function isSourceEntrypoint(specifier) {
return /\.(?:ts|tsx)$/.test(specifier);
}
function runtimeBuildSpecifierFor(specifier) {
const normalized = normalizeEntrypointSpecifier(specifier);
const basename = path.posix.basename(normalized).replace(/\.(?:ts|tsx)$/, ".js");
return `./dist/${basename}`;
}
function normalizeEntrypointSpecifier(specifier) {
const normalized = specifier.replaceAll("\\", "/");
return normalized.startsWith("./") ? normalized : `./${normalized}`;
}
async function findPackageFiles(root, options, depth = 0) {
if (!existsSync(root) || depth > options.maxDepth) {
return [];
@ -1059,8 +1102,13 @@ function packageNpmPackIssues(packageSummary, fixtureReport) {
});
}
const missingEntrypoints = packageSummary.openclaw?.entrypoints
.filter((entrypoint) => !repoPathIncludedInNpmPack(packageSummary, entrypoint.relativePath))
const entrypoints = packageSummary.openclaw?.entrypoints ?? [];
const missingEntrypoints = entrypoints
.filter(
(entrypoint) =>
!repoPathIncludedInNpmPack(packageSummary, entrypoint.relativePath) &&
!hasPackagedRuntimeEntrypoint(entrypoint, packageSummary, entrypoints),
)
.map((entrypoint) => `${entrypoint.kind}:${entrypoint.specifier} -> ${entrypoint.relativePath}`) ?? [];
if (missingEntrypoints.length > 0) {
findings.push({
@ -1088,6 +1136,35 @@ function packageNpmPackMissingMetadata(packageSummary, fixtureReport) {
return missing;
}
function hasPackagedRuntimeEntrypoint(entrypoint, packageSummary, entrypoints) {
if (!isSourceEntrypoint(entrypoint.specifier)) {
return false;
}
const runtimeBuildSpecifier = runtimeBuildSpecifierFor(entrypoint.specifier);
const matchingRuntimeEntrypoint = entrypoints.find(
(candidate) =>
candidate.requiresBuild &&
normalizeEntrypointSpecifier(candidate.specifier) === normalizeEntrypointSpecifier(runtimeBuildSpecifier),
);
if (matchingRuntimeEntrypoint && repoPathIncludedInNpmPack(packageSummary, matchingRuntimeEntrypoint.relativePath)) {
return true;
}
if (
entrypoint.kind === "extension" &&
entrypoints.some(
(candidate) => candidate.kind === "runtimeExtension" && repoPathIncludedInNpmPack(packageSummary, candidate.relativePath),
)
) {
return true;
}
const packageDir = path.posix.dirname(normalizeRepoPath(packageSummary.path));
const runtimeBuildPath = path.posix.join(packageDir === "." ? "" : packageDir, normalizeEntrypointSpecifier(runtimeBuildSpecifier));
return repoPathIncludedInNpmPack(packageSummary, runtimeBuildPath);
}
function packageMinHostVersionDrift(packageSummary) {
const openclaw = packageSummary.openclaw;
if (!nonEmptyString(openclaw?.install?.minHostVersion) || !nonEmptyString(openclaw?.buildOpenClawVersion)) {

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { createCaptureApi } from "./capture-api.js";
import { captureApiOptionsForPlugin } from "./capture-config.js";
import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
import { readOpenClawTargetSurface } from "./openclaw-target.js";
@ -188,7 +189,12 @@ export async function captureEntrypoint(entrypoint, options = {}) {
};
}
const api = createCaptureApi(options.apiOptions);
const apiOptions = await captureApiOptionsForPlugin(options.apiOptions, {
pluginRoot: options.pluginRoot
? path.resolve(options.cwd ?? process.cwd(), options.pluginRoot)
: path.dirname(resolvedEntrypoint),
});
const api = createCaptureApi(apiOptions);
try {
await register(api);
} catch (error) {
@ -199,7 +205,7 @@ export async function captureEntrypoint(entrypoint, options = {}) {
entrypoint: resolvedEntrypoint,
captured: api.getCapturedContracts(),
};
if (options.apiOptions?.retainHandlers === true) {
if (apiOptions?.retainHandlers === true) {
result.retained = api.getRetainedContracts();
}
return result;
@ -228,10 +234,34 @@ export async function captureEntrypointWithMockSdk(entrypoint, options = {}) {
);
return JSON.parse(stdout);
} catch (error) {
const captured = parseCaptureResultFromStdout(error?.stdout);
if (captured) {
return captured;
}
throw classifyMockSdkCaptureError(error);
}
}
function parseCaptureResultFromStdout(stdout) {
if (!stdout) {
return null;
}
try {
const parsed = JSON.parse(stdout);
if (
parsed &&
typeof parsed === "object" &&
typeof parsed.status === "string" &&
Array.isArray(parsed.captured)
) {
return parsed;
}
} catch {
return null;
}
return null;
}
export function classifyMockSdkCaptureError(error) {
const rawMessage = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join("\n");
const missingExport = rawMessage.match(/does not provide an export named ['"]([^'"]+)['"]/)?.[1];

View File

@ -220,10 +220,10 @@ export const issueMetadataByCode = {
title: "providerAuthEnvVars legacy manifest metadata must stay covered",
},
"registration-capture-gap": {
severity: "P1",
severity: "P2",
owner: "inspector",
decision: "inspector-follow-up",
title: "runtime registrations need capture before contract judgment",
title: "runtime registrations need capture evidence before final contract judgment",
},
"runtime-tool-capture": {
severity: "P2",
@ -342,7 +342,10 @@ export function summarizeIssueClasses(issues) {
}
function issueClassFor(code, options) {
if (["unknown-hook-name", "unknown-registration-name", "package-entrypoint-missing", "sdk-export-missing"].includes(code)) {
if (code === "sdk-export-missing" && options.compatRecord) {
return "compat-gap";
}
if (["unknown-hook-name", "unknown-registration-name", "package-entrypoint-missing"].includes(code)) {
return "live-issue";
}
if (code === "missing-compat-record") {
@ -397,7 +400,7 @@ function severityForClass(code, defaultSeverity, options) {
if (
options.issueClass === "live-issue" &&
["none", "untracked"].includes(options.compatStatus) &&
["unknown-hook-name", "unknown-registration-name", "package-entrypoint-missing", "sdk-export-missing"].includes(code)
["unknown-hook-name", "unknown-registration-name", "package-entrypoint-missing"].includes(code)
) {
return "P0";
}

View File

@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createCaptureApi } from "./capture-api.js";
import { captureApiOptionsForPlugin } from "./capture-config.js";
import { createMockSdkPackage } from "./sdk-mock.js";
const options = JSON.parse(process.argv[2] ?? "{}");
@ -30,7 +31,7 @@ async function run(options) {
cleanupTempDirOnExit(workspace);
const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
register(pathToFileURL(loaderPath));
return await captureLinkedEntrypoint(entrypoint, options);
return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
}
function cleanupTempDirOnExit(dir) {
@ -65,7 +66,10 @@ async function captureLinkedEntrypoint(entrypoint, options) {
);
}
const api = createCaptureApi(options.apiOptions);
const apiOptions = await captureApiOptionsForPlugin(options.apiOptions, {
pluginRoot: options.pluginRoot,
});
const api = createCaptureApi(apiOptions);
try {
await register(api);
} catch (error) {
@ -80,7 +84,7 @@ async function captureLinkedEntrypoint(entrypoint, options) {
mockSdk: true,
captured: api.getCapturedContracts(),
};
if (options.apiOptions?.retainHandlers === true) {
if (apiOptions?.retainHandlers === true) {
result.retained = api.getRetainedContracts();
}
return withProcessOutput(result, outputCapture);

View File

@ -225,6 +225,10 @@ export function classifyCompatRecordCoverage({ targetOpenClaw, findings, suggest
continue;
}
if (finding.code === "sdk-export-missing") {
continue;
}
suggestions.push({
fixture: finding.fixture,
code: "missing-compat-record",

View File

@ -551,6 +551,9 @@ function genericExportStatement(name) {
if (name === "defineBundledChannelSetupEntry") {
return "export { defineBundledChannelSetupEntry };";
}
if (name === "loadBundledEntryExportSync") {
return "export { loadBundledEntryExportSync };";
}
if (/^[A-Z].*Schema$/u.test(name)) {
return `export const ${name} = createSchema();`;
}
@ -558,7 +561,13 @@ function genericExportStatement(name) {
}
function genericMockRuntimeSource(options = {}) {
return `${options.includeSdkRuntime ? `function definePluginEntry(entry) {
return `${options.includeSdkRuntime ? `import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const pendingBundledEntryLoads = new Set();
function definePluginEntry(entry) {
if (entry && typeof entry === "object" && typeof entry.register === "function") {
return entry;
}
@ -572,7 +581,7 @@ function defineBundledChannelEntry(entry = {}) {
return {
...entry,
kind: "bundled-channel-entry",
register(api) {
async register(api) {
if (api?.registrationMode === "cli-metadata") {
return entry.registerCliMetadata?.(api);
}
@ -585,7 +594,12 @@ function defineBundledChannelEntry(entry = {}) {
});
}
entry.registerCliMetadata?.(api);
return entry.registerFull?.(api);
const result = entry.registerFull?.(api);
if (result && typeof result.then === "function") {
await result;
}
await drainBundledEntryLoads();
return result;
},
};
}
@ -596,6 +610,46 @@ function defineBundledChannelSetupEntry(entry = {}) {
kind: "bundled-channel-setup-entry",
};
}
function loadBundledEntryExportSync(importMetaUrl, options = {}) {
return (...args) => {
const promise = import(resolveBundledEntryUrl(importMetaUrl, options.specifier)).then((module) => {
const loaded = module[options.exportName] ?? module.default;
return typeof loaded === "function" ? loaded(...args) : loaded;
});
pendingBundledEntryLoads.add(promise);
promise.finally(() => pendingBundledEntryLoads.delete(promise));
return promise;
};
}
async function drainBundledEntryLoads() {
while (pendingBundledEntryLoads.size > 0) {
await Promise.all([...pendingBundledEntryLoads]);
}
}
function resolveBundledEntryUrl(importMetaUrl, specifier) {
const basePath = fileURLToPath(importMetaUrl);
const target = specifier ? path.resolve(path.dirname(basePath), specifier) : basePath;
const resolved = resolveExistingSourcePath(target);
return pathToFileURL(resolved).href;
}
function resolveExistingSourcePath(target) {
if (existsSync(target)) {
return target;
}
const parsed = path.parse(target);
const withoutJsExtension = [".js", ".mjs", ".cjs"].includes(parsed.ext) ? path.join(parsed.dir, parsed.name) : null;
const candidates = [
...(withoutJsExtension ? [\`\${withoutJsExtension}.ts\`, \`\${withoutJsExtension}.mts\`, \`\${withoutJsExtension}.cts\`] : []),
\`\${target}.js\`,
\`\${target}.mjs\`,
\`\${target}.ts\`,
];
return candidates.find((candidate) => existsSync(candidate)) ?? target;
}
` : ""}
function createMockValue(name) {
function fn(...args) {
@ -1122,16 +1176,36 @@ export function normalizeSecretInputString(value) {
return String(value ?? "").trim();
}
function createSimpleSchema(defaultValue) {
return {
parse(value) {
return value === undefined ? defaultValue : value;
},
default(value) {
return createSimpleSchema(value);
},
optional() {
return this;
},
nullable() {
return this;
},
nullish() {
return this;
},
};
}
export function buildSecretInputSchema() {
return { type: "string" };
return createSimpleSchema();
}
export function buildOptionalSecretInputSchema() {
return { anyOf: [buildSecretInputSchema(), { type: "undefined" }] };
return createSimpleSchema();
}
export function buildSecretInputArraySchema() {
return { type: "array", items: buildSecretInputSchema() };
return createSimpleSchema([]);
}
export function registerPluginHttpRoute(options = {}) {

View File

@ -77,6 +77,7 @@ test("capture API accepts custom registrar return profiles", () => {
test("capture API exposes mock context helpers", async () => {
const api = createCaptureApi({
resolvePath: (value) => `/fixture/${value}`,
secretValues: {
token: "redacted",
},
@ -90,6 +91,7 @@ test("capture API exposes mock context helpers", async () => {
assert.deepEqual(await api.store.list(), ["key"]);
assert.equal(api.agent.id, "plugin-inspector-agent");
assert.equal(api.paths.dataDir, ".plugin-inspector/data");
assert.equal(api.resolvePath("state"), "/fixture/state");
});
test("capture API can retain handlers for probes", () => {

View File

@ -162,7 +162,7 @@ test("ci policy surfaces P0 live issues without blocking default lanes", () => {
code: "legacy-before-agent-start",
},
{
severity: "P1",
severity: "P2",
issueClass: "inspector-gap",
fixture: "wecom",
code: "registration-capture-gap",
@ -260,7 +260,7 @@ test("ci policy writer emits JSON and Markdown artifacts", async () => {
function compatibilityReport(overrides = {}) {
const issues = overrides.issues ?? [
{
severity: "P1",
severity: "P2",
issueClass: "inspector-gap",
fixture: "fixture",
code: "registration-capture-gap",

View File

@ -18,17 +18,17 @@ test("ci summary rolls up compatibility, policy, ref diff, and profile findings"
suggestionCount: 3,
issueCount: 4,
p0IssueCount: 1,
p1IssueCount: 1,
p1IssueCount: 0,
liveIssueCount: 1,
compatGapCount: 1,
},
issues: [
{
severity: "P1",
severity: "P2",
issueClass: "inspector-gap",
fixture: "fixture",
code: "registration-capture-gap",
title: "runtime registrations need capture",
title: "runtime registrations need capture evidence",
decision: "inspector-follow-up",
},
],

View File

@ -26,7 +26,7 @@ test("contract coverage fails missing evidence and P1 probe gaps", () => {
fixture: "fixture",
severity: "P1",
issueClass: "inspector-gap",
code: "registration-capture-gap",
code: "conversation-access-hook",
evidence: [],
},
],
@ -165,4 +165,20 @@ test("contract coverage requires compat record reconciliation evidence", () => {
compatRecord: "fixture.provider-auth-env-vars",
});
assert.deepEqual(validateContractCoverage(report), []);
report.logs = [];
report.issues.push({
id: "PLUGIN-COMPAT",
fixture: "fixture",
severity: "P1",
issueClass: "compat-gap",
code: "provider-auth-env-vars",
evidence: ["fixture"],
compatRecord: "fixture.provider-auth-env-vars",
});
report.contractProbes.push({
fixture: "fixture",
id: "compat.provider-auth-env-vars:fixture",
});
assert.deepEqual(validateContractCoverage(report), []);
});

View File

@ -46,8 +46,8 @@ test("contract probes map issue findings to executable backlog rows", () => {
assert.deepEqual(
probes.map((probe) => [probe.id, probe.priority, probe.target]),
[
["api.capture.runtime-registrars:wecom", "P1", "inspector-capture-api"],
["sdk.import.package-export-cold-import:codex-app-server", "P1", "sdk-alias"],
["api.capture.runtime-registrars:wecom", "P2", "inspector-capture-api"],
["manifest.schema.top-level-fields:agentchat", "P3", "manifest-loader"],
],
);
@ -55,6 +55,7 @@ test("contract probes map issue findings to executable backlog rows", () => {
test("contract probe priority escalates critical codes and high-priority fixtures", () => {
assert.equal(probePriority("sdk-export-missing", "medium"), "P1");
assert.equal(probePriority("registration-capture-gap", "high"), "P2");
assert.equal(probePriority("manifest-unknown-fields", "high"), "P2");
assert.equal(probePriority("manifest-unknown-fields", "medium"), "P3");
});

View File

@ -197,6 +197,35 @@ test("capture entrypoint can mock OpenClaw plugin SDK imports", async () => {
);
});
test("mock capture accepts valid output when plugin code dirties process exit code", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-exit-code-"));
const entrypoint = path.join(dir, "index.mjs");
await writeFile(
entrypoint,
[
'import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";',
"process.exitCode = 1;",
"export default definePluginEntry({",
" register(api) {",
" api.registerProvider({ id: 'fixture-provider' });",
" },",
"});",
].join("\n"),
"utf8",
);
const result = await captureEntrypoint("index.mjs", {
cwd: dir,
pluginRoot: dir,
mockSdk: true,
});
assert.equal(result.status, "captured");
assert.deepEqual(result.captured.map((item) => `${item.kind}:${item.name}`), [
"registration:registerProvider",
]);
});
test("mock capture prefers discovered bare mocks over installed dependency exports", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-bare-capture-"));
await mkdir(path.join(dir, "node_modules/typebox"), { recursive: true });
@ -264,3 +293,56 @@ test("mock capture expands bundled channel entry registration shells", async ()
"registration:registerTool",
]);
});
test("mock capture follows bundled channel linked registerFull exports", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-bundled-channel-linked-capture-"));
await writeFile(
path.join(dir, "index.ts"),
[
'import { defineBundledChannelEntry, loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract";',
"",
"function registerFull(api) {",
" const register = loadBundledEntryExportSync(import.meta.url, {",
" specifier: './api.js',",
" exportName: 'registerFixtureFull',",
" });",
" register(api);",
"}",
"",
"export default defineBundledChannelEntry({",
" id: 'fixture-channel',",
" name: 'Fixture Channel',",
" description: 'Fixture channel',",
" plugin: { specifier: './channel-plugin-api.js', exportName: 'fixtureChannel' },",
" registerFull,",
"});",
].join("\n"),
"utf8",
);
await writeFile(
path.join(dir, "api.ts"),
[
'import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";',
"",
"const schema = buildSecretInputSchema().optional();",
"",
"export function registerFixtureFull(api) {",
" schema.parse(undefined);",
" api.registerCommand({ name: 'fixture.command', run() {} });",
"}",
].join("\n"),
"utf8",
);
const result = await captureEntrypoint("index.ts", {
cwd: dir,
pluginRoot: dir,
mockSdk: true,
});
assert.equal(result.status, "captured");
assert.deepEqual(result.captured.map((item) => `${item.kind}:${item.name}`), [
"registration:registerChannel",
"registration:registerCommand",
]);
});

View File

@ -19,18 +19,18 @@ test("issue ids are stable fingerprints", () => {
test("issue classification separates live breaks from compat and deprecation buckets", () => {
const cases = [
{
name: "untracked SDK alias is a blocking live issue",
name: "untracked SDK alias is a compat gap",
finding: { code: "sdk-export-missing", compatRecord: "plugin-sdk-export-aliases" },
targetOpenClaw: { compatRecordStatuses: {} },
metadata: { severity: "P1" },
expected: { issueClass: "live-issue", compatStatus: "untracked", severity: "P0", live: true },
expected: { issueClass: "compat-gap", compatStatus: "untracked", severity: "P1", live: false },
},
{
name: "active SDK alias compat avoids false P0 escalation",
name: "active SDK alias compat stays a compat row",
finding: { code: "sdk-export-missing", compatRecord: "plugin-sdk-export-aliases" },
targetOpenClaw: { compatRecordStatuses: { "plugin-sdk-export-aliases": "active" } },
metadata: { severity: "P1" },
expected: { issueClass: "live-issue", compatStatus: "active", severity: "P1", live: true },
expected: { issueClass: "compat-gap", compatStatus: "active", severity: "P1", live: false },
},
{
name: "deprecated compat remains warning-class even when used",
@ -112,17 +112,17 @@ test("issue builder applies metadata and class summaries", () => {
assert.deepEqual(
issues.map((issue) => [issue.fixture, issue.code, issue.severity, issue.issueClass, issue.status]),
[
["codex-app-server", "sdk-export-missing", "P0", "live-issue", "blocking"],
["wecom", "registration-capture-gap", "P1", "inspector-gap", "open"],
["codex-app-server", "sdk-export-missing", "P1", "compat-gap", "open"],
["agentchat", "manifest-unknown-fields", "P2", "upstream-metadata", "open"],
["wecom", "registration-capture-gap", "P2", "inspector-gap", "open"],
],
);
assert.deepEqual(summarizeIssueClasses(issues), {
"compat-gap": 0,
"compat-gap": 1,
"deprecation-warning": 0,
"fixture-regression": 0,
"inspector-gap": 1,
"live-issue": 1,
"live-issue": 0,
"upstream-metadata": 1,
});
});

View File

@ -865,6 +865,79 @@ test("package contract classifier reports advertised npm pack blockers", () => {
assert.equal(globResult.warnings.some((finding) => finding.code.startsWith("package-npm-pack-")), false);
});
test("package contract classifier accepts built runtime entries for source package metadata", () => {
const result = classifyPackageContracts({
fixture: {
id: "fixture",
path: "plugins/fixture",
},
inspection: {
registrations: ["registerTool"],
},
fixtureReport: {
pluginManifests: [{ path: "plugins/fixture/openclaw.plugin.json", version: "1.0.0" }],
package: {
path: "plugins/fixture/package.json",
name: "@openclaw/fixture-plugin",
version: "1.0.0",
npmPack: {
advertised: true,
private: false,
filesMode: "allowlist",
files: ["dist/**", "openclaw.plugin.json"],
invalidFileSpecs: [],
},
dependencies: [],
peerDependencies: ["openclaw"],
optionalDependencies: [],
openclaw: {
compatPluginApi: "^1.0.0",
install: {
npmSpec: "@openclaw/fixture-plugin",
},
release: {
publishToNpm: true,
},
entrypoints: [
{
kind: "extension",
specifier: "./index.ts",
relativePath: "plugins/fixture/index.ts",
exists: false,
requiresBuild: false,
},
{
kind: "runtimeExtension",
specifier: "./dist/index.js",
relativePath: "plugins/fixture/dist/index.js",
exists: true,
requiresBuild: true,
},
{
kind: "setupEntry",
specifier: "./setup-entry.ts",
relativePath: "plugins/fixture/setup-entry.ts",
exists: false,
requiresBuild: false,
},
{
kind: "runtimeExtension",
specifier: "./dist/setup-entry.js",
relativePath: "plugins/fixture/dist/setup-entry.js",
exists: true,
requiresBuild: true,
},
],
},
},
},
});
assert.equal(result.warnings.some((finding) => finding.code === "package-entrypoint-missing"), false);
assert.equal(result.warnings.some((finding) => finding.code === "package-npm-pack-entrypoint-missing"), false);
assert.equal(result.decisions.some((decision) => decision.seam === "package-entrypoint"), false);
});
test("target OpenClaw coverage classifier reports missing public surface", () => {
const result = classifyTargetOpenClawCoverage({
fixture: { id: "fixture" },

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { test } from "node:test";
import {
buildRuntimeCaptureReport,
captureEntrypoint,
inspectCompatibilityFixtureSet,
loadPluginRootConfig,
writeRuntimeCaptureReport,
@ -274,6 +275,83 @@ test("runtime capture supports TypeScript entrypoints, SDK subpaths, external mo
assert.match(captureReport.results[0].processOutput.stdout, /late plugin noise/);
});
test("runtime capture synthesizes manifest config before plugin registration", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-config-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });
await writeFile(
path.join(rootDir, "package.json"),
`${JSON.stringify(
{
name: "openclaw-configured-memory",
version: "1.0.0",
type: "module",
openclaw: {
extensions: ["src/index.ts"],
compat: { pluginApi: "^1.0.0" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: "configured-memory",
configSchema: {
type: "object",
properties: {
embedding: {
type: "object",
minProperties: 1,
properties: {
provider: { type: "string" },
model: { type: "string" },
},
},
autoCapture: { type: "boolean" },
autoRecall: { type: "boolean" },
},
required: ["embedding"],
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "src", "index.ts"),
[
'import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";',
"export default definePluginEntry({",
" register(api) {",
" if (!api.pluginConfig?.embedding) {",
" api.registerService({ id: 'configured-memory-disabled', start() {} });",
" return;",
" }",
" api.on('agent_end', () => undefined);",
" },",
"});",
].join("\n"),
"utf8",
);
const result = await captureEntrypoint("src/index.ts", {
cwd: rootDir,
pluginRoot: rootDir,
mockSdk: true,
});
assert.equal(result.status, "captured");
assert.deepEqual(
result.captured.map((item) => `${item.kind}:${item.name}`),
["hook:agent_end"],
);
});
test("runtime capture supports namespace imports from mocked externals", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-zod-namespace-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });