diff --git a/README.md b/README.md index c28ea74..90155ae 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 5985fc8..3aba29e 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/index.js b/src/index.js index 12d6046..16380a0 100644 --- a/src/index.js +++ b/src/index.js @@ -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, diff --git a/src/openclaw-target.js b/src/openclaw-target.js new file mode 100644 index 0000000..2e050a0 --- /dev/null +++ b/src/openclaw-target.js @@ -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)]; +} diff --git a/test/openclaw-target.test.js b/test/openclaw-target.test.js new file mode 100644 index 0000000..51bdbbd --- /dev/null +++ b/test/openclaw-target.test.js @@ -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"]); +});