feat: add OpenClaw target surface parser
Add reusable OpenClaw target checkout parsing for compatibility records, hooks, registrars, SDK exports, and manifest type fields.
This commit is contained in:
parent
22cb50fdbd
commit
e7747f74ca
@ -51,6 +51,7 @@ import {
|
||||
createCaptureApi,
|
||||
inspectFixtureSet,
|
||||
loadInspectorConfig,
|
||||
readOpenClawTargetSurface,
|
||||
renderColdImportReadinessMarkdown,
|
||||
renderContractCaptureMarkdown,
|
||||
renderPlatformProbesMarkdown,
|
||||
@ -75,6 +76,8 @@ await writeContractCapture(capture);
|
||||
const readiness = buildColdImportReadiness({ report });
|
||||
await writeColdImportReadiness(readiness);
|
||||
|
||||
const target = await readOpenClawTargetSurface({ manifest: config });
|
||||
|
||||
const platformProbes = buildPlatformProbes({ plan: existingWorkspacePlan });
|
||||
await writePlatformProbes(platformProbes);
|
||||
```
|
||||
@ -84,6 +87,8 @@ await writePlatformProbes(platformProbes);
|
||||
Default inspection is offline and credential-free. It reads manifests, package
|
||||
metadata, and source files, then reports observed `api.on(...)`,
|
||||
`api.register*`, `define*`, SDK imports, and manifest contracts.
|
||||
OpenClaw target checkout parsing is limited to public compatibility registries,
|
||||
SDK package exports, manifest types, hooks, and captured registrar metadata.
|
||||
|
||||
Cold import capture and synthetic contract probes are explicit opt-in modes.
|
||||
Live lanes will stay credential-gated and must never run in default CI.
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"./capture-api": "./src/capture-api.js",
|
||||
"./cold-import-readiness": "./src/cold-import-readiness.js",
|
||||
"./contract-capture": "./src/contract-capture.js",
|
||||
"./openclaw-target": "./src/openclaw-target.js",
|
||||
"./platform-probes": "./src/platform-probes.js"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@ -46,6 +46,15 @@ export {
|
||||
knownIssueCodes,
|
||||
summarizeIssueClasses,
|
||||
} from "./issues.js";
|
||||
export {
|
||||
defaultOpenClawCheckoutPaths,
|
||||
openClawTargetPathCandidates,
|
||||
parseCompatRecordEntries,
|
||||
parseExportedStringArray,
|
||||
parsePluginSdkExports,
|
||||
parseTypeFields,
|
||||
readOpenClawTargetSurface,
|
||||
} from "./openclaw-target.js";
|
||||
export {
|
||||
captureEntrypoint,
|
||||
inspectFixtureSet,
|
||||
|
||||
179
src/openclaw-target.js
Normal file
179
src/openclaw-target.js
Normal file
@ -0,0 +1,179 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const defaultOpenClawCheckoutPaths = ["./openclaw", "../openclaw"];
|
||||
|
||||
export async function readOpenClawTargetSurface(options = {}) {
|
||||
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
||||
const configuredPath = options.configuredPath;
|
||||
|
||||
if (configuredPath === false) {
|
||||
return emptyTargetSurface({ configuredPath: null, status: "disabled" });
|
||||
}
|
||||
|
||||
const requestedPaths = openClawTargetPathCandidates(options.manifest, configuredPath);
|
||||
if (requestedPaths.length === 0) {
|
||||
return emptyTargetSurface({ configuredPath: null, status: "not-configured" });
|
||||
}
|
||||
|
||||
const match = findTargetCheckout(rootDir, requestedPaths);
|
||||
if (!match) {
|
||||
return emptyTargetSurface({
|
||||
configuredPath: requestedPaths[0],
|
||||
searchedPaths: requestedPaths,
|
||||
status: "missing",
|
||||
});
|
||||
}
|
||||
|
||||
const { requestedPath, resolvedPath, registryPath } = match;
|
||||
const hookTypesPath = path.join(resolvedPath, "src/plugins/hook-types.ts");
|
||||
const apiBuilderPath = path.join(resolvedPath, "src/plugins/api-builder.ts");
|
||||
const capturedRegistrationPath = path.join(resolvedPath, "src/plugins/captured-registration.ts");
|
||||
const manifestTypesPath = path.join(resolvedPath, "src/plugins/manifest.ts");
|
||||
const packagePath = path.join(resolvedPath, "package.json");
|
||||
|
||||
const registrySource = await readFile(registryPath, "utf8");
|
||||
const compatRecordEntries = parseCompatRecordEntries(registrySource);
|
||||
const hookTypesSource = existsSync(hookTypesPath) ? await readFile(hookTypesPath, "utf8") : "";
|
||||
const hookNames = hookTypesSource ? parseExportedStringArray(hookTypesSource, "PLUGIN_HOOK_NAMES") : [];
|
||||
const apiBuilderSource = existsSync(apiBuilderPath) ? await readFile(apiBuilderPath, "utf8") : "";
|
||||
const apiRegistrars = apiBuilderSource ? parseApiRegistrars(apiBuilderSource) : [];
|
||||
const manifestTypesSource = existsSync(manifestTypesPath) ? await readFile(manifestTypesPath, "utf8") : "";
|
||||
const manifestFields = manifestTypesSource ? parseTypeFields(manifestTypesSource, "PluginManifest") : [];
|
||||
const manifestContractFields = manifestTypesSource ? parseTypeFields(manifestTypesSource, "PluginManifestContracts") : [];
|
||||
const capturedRegistrars = existsSync(capturedRegistrationPath)
|
||||
? parseCapturedRegistrars(await readFile(capturedRegistrationPath, "utf8"))
|
||||
: [];
|
||||
const sdkExports = existsSync(packagePath)
|
||||
? parsePluginSdkExports(JSON.parse(await readFile(packagePath, "utf8")))
|
||||
: [];
|
||||
|
||||
return {
|
||||
configuredPath: requestedPath,
|
||||
searchedPaths: requestedPaths,
|
||||
status: "ok",
|
||||
compatRegistryPath: relativePath(rootDir, registryPath),
|
||||
compatRecordCount: compatRecordEntries.length,
|
||||
compatRecords: compatRecordEntries.map((record) => record.code).sort(),
|
||||
compatRecordStatuses: Object.fromEntries(compatRecordEntries.map((record) => [record.code, record.status])),
|
||||
hookTypesPath: existsSync(hookTypesPath) ? relativePath(rootDir, hookTypesPath) : null,
|
||||
hookNameCount: hookNames.length,
|
||||
hookNames,
|
||||
apiBuilderPath: existsSync(apiBuilderPath) ? relativePath(rootDir, apiBuilderPath) : null,
|
||||
apiRegistrarCount: apiRegistrars.length,
|
||||
apiRegistrars,
|
||||
capturedRegistrationPath: existsSync(capturedRegistrationPath) ? relativePath(rootDir, capturedRegistrationPath) : null,
|
||||
capturedRegistrarCount: capturedRegistrars.length,
|
||||
capturedRegistrars,
|
||||
packagePath: existsSync(packagePath) ? relativePath(rootDir, packagePath) : null,
|
||||
sdkExportCount: sdkExports.length,
|
||||
sdkExports,
|
||||
manifestTypesPath: existsSync(manifestTypesPath) ? relativePath(rootDir, manifestTypesPath) : null,
|
||||
manifestFieldCount: manifestFields.length,
|
||||
manifestFields,
|
||||
manifestContractFieldCount: manifestContractFields.length,
|
||||
manifestContractFields,
|
||||
};
|
||||
}
|
||||
|
||||
export function openClawTargetPathCandidates(manifest, configuredPath) {
|
||||
if (typeof configuredPath === "string") {
|
||||
return [configuredPath];
|
||||
}
|
||||
return unique([manifest?.openclaw?.defaultCheckoutPath, ...defaultOpenClawCheckoutPaths].filter(Boolean));
|
||||
}
|
||||
|
||||
export function parseCompatRecordEntries(source) {
|
||||
const entries = [];
|
||||
for (const match of source.matchAll(/\{[\s\S]*?\bcode:\s*["'`]([^"'`]+)["'`][\s\S]*?\bstatus:\s*["'`]([^"'`]+)["'`][\s\S]*?\}/g)) {
|
||||
entries.push({ code: match[1], status: match[2] });
|
||||
}
|
||||
return dedupeBy(entries, (entry) => entry.code).sort((left, right) => left.code.localeCompare(right.code));
|
||||
}
|
||||
|
||||
export function parsePluginSdkExports(packageJson) {
|
||||
return Object.keys(packageJson.exports ?? {})
|
||||
.filter((specifier) => specifier === "./plugin-sdk" || specifier.startsWith("./plugin-sdk/"))
|
||||
.map((specifier) => `openclaw/${specifier.slice(2)}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function parseExportedStringArray(source, exportName) {
|
||||
const match = source.match(new RegExp(`export\\s+const\\s+${exportName}\\s*=\\s*\\[([\\s\\S]*?)\\]\\s+as\\s+const`));
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return unique([...match[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((item) => item[1])).sort();
|
||||
}
|
||||
|
||||
export function parseTypeFields(source, typeName) {
|
||||
const marker = `export type ${typeName} = {`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
return [];
|
||||
}
|
||||
const bodyStart = start + marker.length;
|
||||
const end = source.indexOf("\n};", bodyStart);
|
||||
if (end === -1) {
|
||||
return [];
|
||||
}
|
||||
const body = source.slice(bodyStart, end);
|
||||
return unique(
|
||||
[...body.matchAll(/^\s*([A-Za-z][A-Za-z0-9]*)\??:/gm)]
|
||||
.map((match) => match[1])
|
||||
.filter((field) => !field.startsWith("PluginManifest")),
|
||||
).sort();
|
||||
}
|
||||
|
||||
function findTargetCheckout(rootDir, requestedPaths) {
|
||||
for (const requestedPath of requestedPaths) {
|
||||
const resolvedPath = path.resolve(rootDir, requestedPath);
|
||||
const registryPath = path.join(resolvedPath, "src/plugins/compat/registry.ts");
|
||||
if (existsSync(registryPath)) {
|
||||
return { requestedPath, resolvedPath, registryPath };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status }) {
|
||||
return {
|
||||
configuredPath,
|
||||
searchedPaths,
|
||||
status,
|
||||
compatRecords: [],
|
||||
compatRecordStatuses: {},
|
||||
hookNames: [],
|
||||
apiRegistrars: [],
|
||||
capturedRegistrars: [],
|
||||
sdkExports: [],
|
||||
manifestFields: [],
|
||||
manifestContractFields: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseCapturedRegistrars(source) {
|
||||
return unique([...source.matchAll(/^\s*(register[A-Za-z0-9]+)\s*\(/gm)].map((match) => match[1])).sort();
|
||||
}
|
||||
|
||||
function parseApiRegistrars(source) {
|
||||
return unique([...source.matchAll(/\b(register[A-Za-z0-9]+)\b/g)].map((match) => match[1])).sort();
|
||||
}
|
||||
|
||||
function relativePath(rootDir, filePath) {
|
||||
return path.relative(rootDir, filePath).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function dedupeBy(values, keyForValue) {
|
||||
const byKey = new Map();
|
||||
for (const value of values) {
|
||||
byKey.set(keyForValue(value), value);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
127
test/openclaw-target.test.js
Normal file
127
test/openclaw-target.test.js
Normal file
@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
openClawTargetPathCandidates,
|
||||
parseCompatRecordEntries,
|
||||
parsePluginSdkExports,
|
||||
parseTypeFields,
|
||||
readOpenClawTargetSurface,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("OpenClaw target parser reads public target surface facts", async (t) => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-openclaw-target-"));
|
||||
t.after(() => rm(rootDir, { recursive: true, force: true }));
|
||||
|
||||
const targetRoot = path.join(rootDir, "openclaw");
|
||||
await mkdir(path.join(targetRoot, "src/plugins/compat"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(targetRoot, "src/plugins/compat/registry.ts"),
|
||||
`export const records = [
|
||||
{ code: "sdk.import.root-barrel-cold-import", status: "deprecated" },
|
||||
{ code: "hook.before_tool_call.terminal-block-approval", status: "supported" },
|
||||
];\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetRoot, "src/plugins/hook-types.ts"),
|
||||
`export const PLUGIN_HOOK_NAMES = ["before_tool_call", "llm_input"] as const;\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetRoot, "src/plugins/api-builder.ts"),
|
||||
`api.registerTool(tool); api.registerService(service); api.registerTool(other);\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetRoot, "src/plugins/captured-registration.ts"),
|
||||
`export function createApi() {
|
||||
return {
|
||||
registerTool(tool) {},
|
||||
registerService(service) {},
|
||||
};
|
||||
}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetRoot, "src/plugins/manifest.ts"),
|
||||
`export type PluginManifest = {
|
||||
id: string;
|
||||
PluginManifestCompat?: never;
|
||||
contracts?: PluginManifestContracts;
|
||||
};
|
||||
export type PluginManifestContracts = {
|
||||
tools?: unknown;
|
||||
channels?: unknown;
|
||||
};\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
exports: {
|
||||
"./plugin-sdk": "./dist/plugin-sdk.js",
|
||||
"./plugin-sdk/channels": "./dist/channels.js",
|
||||
".": "./dist/index.js",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const target = await readOpenClawTargetSurface({
|
||||
rootDir,
|
||||
manifest: { openclaw: { defaultCheckoutPath: "./openclaw" } },
|
||||
});
|
||||
|
||||
assert.equal(target.status, "ok");
|
||||
assert.equal(target.configuredPath, "./openclaw");
|
||||
assert.deepEqual(target.compatRecords, [
|
||||
"hook.before_tool_call.terminal-block-approval",
|
||||
"sdk.import.root-barrel-cold-import",
|
||||
]);
|
||||
assert.deepEqual(target.compatRecordStatuses, {
|
||||
"hook.before_tool_call.terminal-block-approval": "supported",
|
||||
"sdk.import.root-barrel-cold-import": "deprecated",
|
||||
});
|
||||
assert.deepEqual(target.hookNames, ["before_tool_call", "llm_input"]);
|
||||
assert.deepEqual(target.apiRegistrars, ["registerService", "registerTool"]);
|
||||
assert.deepEqual(target.capturedRegistrars, ["registerService", "registerTool"]);
|
||||
assert.deepEqual(target.sdkExports, ["openclaw/plugin-sdk", "openclaw/plugin-sdk/channels"]);
|
||||
assert.deepEqual(target.manifestFields, ["contracts", "id"]);
|
||||
assert.deepEqual(target.manifestContractFields, ["channels", "tools"]);
|
||||
assert.equal(target.compatRegistryPath, "openclaw/src/plugins/compat/registry.ts");
|
||||
});
|
||||
|
||||
test("OpenClaw target parser reports disabled and missing targets", async () => {
|
||||
assert.equal((await readOpenClawTargetSurface({ configuredPath: false })).status, "disabled");
|
||||
|
||||
const missing = await readOpenClawTargetSurface({
|
||||
rootDir: "/tmp",
|
||||
configuredPath: "./missing-openclaw",
|
||||
});
|
||||
assert.equal(missing.status, "missing");
|
||||
assert.deepEqual(missing.searchedPaths, ["./missing-openclaw"]);
|
||||
});
|
||||
|
||||
test("OpenClaw target parsing helpers stay deterministic", () => {
|
||||
assert.deepEqual(openClawTargetPathCandidates({ openclaw: { defaultCheckoutPath: "../target" } }), [
|
||||
"../target",
|
||||
"./openclaw",
|
||||
"../openclaw",
|
||||
]);
|
||||
assert.deepEqual(parsePluginSdkExports({ exports: { "./plugin-sdk": "", "./plugin-sdk/tools": "", ".": "" } }), [
|
||||
"openclaw/plugin-sdk",
|
||||
"openclaw/plugin-sdk/tools",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
parseCompatRecordEntries(`
|
||||
{ code: "b", status: "supported" }
|
||||
{ code: "a", status: "deprecated" }
|
||||
{ code: "b", status: "supported" }
|
||||
`).map((entry) => entry.code),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.deepEqual(parseTypeFields("export type PluginManifest = {\n id?: string;\n};", "PluginManifest"), ["id"]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user