mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 03:44:57 +00:00
333 lines
9.5 KiB
TypeScript
333 lines
9.5 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js";
|
|
import {
|
|
listGitTrackedFiles,
|
|
toRepoPath,
|
|
toRepoRelativePath,
|
|
} from "../../test-utils/repo-files.js";
|
|
|
|
type PluginManifestFile = {
|
|
id?: unknown;
|
|
contracts?: {
|
|
tools?: unknown;
|
|
};
|
|
};
|
|
|
|
function walkFiles(dir: string): string[] {
|
|
const gitFiles = listGitFiles(dir);
|
|
if (gitFiles) {
|
|
return gitFiles;
|
|
}
|
|
|
|
const files: string[] = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...walkFiles(entryPath));
|
|
continue;
|
|
}
|
|
files.push(entryPath);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function repoRelativePath(filePath: string): string {
|
|
return toRepoRelativePath(process.cwd(), filePath);
|
|
}
|
|
|
|
function isSkippedRepoPath(relativePath: string): boolean {
|
|
return relativePath
|
|
.split("/")
|
|
.some((part) => part === "node_modules" || part === "dist" || part.startsWith("."));
|
|
}
|
|
|
|
function listGitFiles(dir: string): string[] | null {
|
|
const relativeDir = repoRelativePath(dir);
|
|
if (!relativeDir || relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) {
|
|
return null;
|
|
}
|
|
const files = listGitTrackedFiles({ pathspecs: relativeDir });
|
|
if (!files) {
|
|
return null;
|
|
}
|
|
return files
|
|
.filter((line) => !isSkippedRepoPath(line))
|
|
.map((line) => path.join(process.cwd(), ...line.split("/")))
|
|
.filter((filePath) => fs.existsSync(filePath))
|
|
.toSorted();
|
|
}
|
|
|
|
function listGitPluginManifestPaths(extensionsDir: string): string[] | null {
|
|
const relativeDir = repoRelativePath(extensionsDir);
|
|
if (!relativeDir || relativeDir.startsWith("..") || path.isAbsolute(relativeDir)) {
|
|
return null;
|
|
}
|
|
const files = listGitTrackedFiles({ pathspecs: relativeDir });
|
|
if (!files) {
|
|
return null;
|
|
}
|
|
return files
|
|
.filter((line) => /^extensions\/[^/]+\/openclaw\.plugin\.json$/u.test(line))
|
|
.map((line) => path.join(process.cwd(), ...line.split("/")))
|
|
.filter((filePath) => fs.existsSync(filePath))
|
|
.toSorted();
|
|
}
|
|
|
|
function listPluginManifestPaths(extensionsDir: string): string[] {
|
|
const gitPaths = listGitPluginManifestPaths(extensionsDir);
|
|
if (gitPaths) {
|
|
return gitPaths;
|
|
}
|
|
|
|
return fs
|
|
.readdirSync(extensionsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
.map((entry) => path.join(extensionsDir, entry.name, "openclaw.plugin.json"))
|
|
.filter((manifestPath) => fs.existsSync(manifestPath));
|
|
}
|
|
|
|
function isProductionSource(filePath: string): boolean {
|
|
if (!/\.(?:cjs|mjs|js|ts|tsx)$/.test(filePath)) {
|
|
return false;
|
|
}
|
|
const normalized = toRepoPath(filePath);
|
|
return !/(\.test\.|\.spec\.|\/__tests__\/|\/test-support\/)/.test(normalized);
|
|
}
|
|
|
|
function readBalancedCallArguments(source: string, openParenIndex: number): string | undefined {
|
|
let depth = 0;
|
|
let quote: '"' | "'" | "`" | undefined;
|
|
let escaped = false;
|
|
for (let index = openParenIndex; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (!char) {
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (char === quote) {
|
|
quote = undefined;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'" || char === "`") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === "(" || char === "{" || char === "[") {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
if (char === ")" || char === "}" || char === "]") {
|
|
depth -= 1;
|
|
if (depth === 0 && char === ")") {
|
|
return source.slice(openParenIndex + 1, index);
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function listRegisterToolCalls(source: string): string[] {
|
|
const calls: string[] = [];
|
|
const pattern = /\bregisterTool\s*\(/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = pattern.exec(source))) {
|
|
const openParenIndex = source.indexOf("(", match.index);
|
|
const args = readBalancedCallArguments(source, openParenIndex);
|
|
if (args !== undefined) {
|
|
calls.push(args);
|
|
}
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
function splitTopLevelArgs(args: string): string[] {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let quote: '"' | "'" | "`" | undefined;
|
|
let escaped = false;
|
|
let start = 0;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const char = args[index];
|
|
if (!char) {
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (char === quote) {
|
|
quote = undefined;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'" || char === "`") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === "(" || char === "{" || char === "[") {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
if (char === ")" || char === "}" || char === "]") {
|
|
depth -= 1;
|
|
continue;
|
|
}
|
|
if (char === "," && depth === 0) {
|
|
const part = args.slice(start, index).trim();
|
|
if (part.length > 0) {
|
|
parts.push(part);
|
|
}
|
|
start = index + 1;
|
|
}
|
|
}
|
|
const part = args.slice(start).trim();
|
|
if (part.length > 0) {
|
|
parts.push(part);
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function extractStringLiterals(source: string): string[] {
|
|
const names: string[] = [];
|
|
const pattern = /["']([^"']+)["']/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = pattern.exec(source))) {
|
|
if (match[1]) {
|
|
names.push(match[1]);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function extractStaticRegisteredToolNamesFromObject(source: string): string[] {
|
|
const names = new Set<string>();
|
|
const namesPattern = /\bnames\s*:\s*\[([\s\S]*?)\]/g;
|
|
let namesMatch: RegExpExecArray | null;
|
|
while ((namesMatch = namesPattern.exec(source))) {
|
|
for (const name of extractStringLiterals(namesMatch[1] ?? "")) {
|
|
names.add(name);
|
|
}
|
|
}
|
|
|
|
const namePattern = /\bname\s*:\s*["']([^"']+)["']/g;
|
|
let nameMatch: RegExpExecArray | null;
|
|
while ((nameMatch = namePattern.exec(source))) {
|
|
if (nameMatch[1]) {
|
|
names.add(nameMatch[1]);
|
|
}
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
function extractStaticRegisteredToolNames(callArgs: string): string[] {
|
|
const args = splitTopLevelArgs(callArgs);
|
|
const names = new Set<string>();
|
|
const firstArg = args[0]?.trim() ?? "";
|
|
const optionsArg = args[1]?.trim() ?? "";
|
|
if (firstArg.startsWith("{")) {
|
|
for (const name of extractStaticRegisteredToolNamesFromObject(firstArg)) {
|
|
names.add(name);
|
|
}
|
|
}
|
|
if (optionsArg.startsWith("{")) {
|
|
for (const name of extractStaticRegisteredToolNamesFromObject(optionsArg)) {
|
|
names.add(name);
|
|
}
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
function readManifest(manifestPath: string): PluginManifestFile {
|
|
return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as PluginManifestFile;
|
|
}
|
|
|
|
function normalizeManifestTools(value: unknown): string[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return value.filter((item): item is string => typeof item === "string" && item.trim() !== "");
|
|
}
|
|
|
|
describe("bundled plugin tool manifest contracts", () => {
|
|
let toolContractFailures: string[] = [];
|
|
|
|
beforeAll(() => {
|
|
listGitTrackedFiles({ pathspecs: "extensions" });
|
|
toolContractFailures = collectToolContractFailures(path.join(process.cwd(), "extensions"));
|
|
});
|
|
|
|
it("lists plugin tool contract inputs from git without walking extension roots", () => {
|
|
const extensionsDir = path.join(process.cwd(), "extensions");
|
|
expectNoReaddirSyncDuring(() => {
|
|
const manifestPaths = listPluginManifestPaths(extensionsDir);
|
|
const sourceFiles = manifestPaths[0]
|
|
? walkFiles(path.dirname(manifestPaths[0])).filter(isProductionSource)
|
|
: [];
|
|
|
|
expect(manifestPaths.length).toBeGreaterThan(0);
|
|
expect(sourceFiles.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it("declares every production registerTool owner in contracts.tools", () => {
|
|
expect(toolContractFailures).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
function collectToolContractFailures(extensionsDir: string): string[] {
|
|
const failures: string[] = [];
|
|
|
|
for (const manifestPath of listPluginManifestPaths(extensionsDir)) {
|
|
const pluginDir = path.dirname(manifestPath);
|
|
const manifest = readManifest(manifestPath);
|
|
const pluginId = typeof manifest.id === "string" ? manifest.id : path.basename(pluginDir);
|
|
const declaredTools = new Set(normalizeManifestTools(manifest.contracts?.tools));
|
|
const registeredNames = new Set<string>();
|
|
let registerCallCount = 0;
|
|
|
|
for (const filePath of walkFiles(pluginDir).filter(isProductionSource)) {
|
|
const source = fs.readFileSync(filePath, "utf-8");
|
|
for (const call of listRegisterToolCalls(source)) {
|
|
registerCallCount += 1;
|
|
for (const name of extractStaticRegisteredToolNames(call)) {
|
|
registeredNames.add(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (registerCallCount === 0) {
|
|
continue;
|
|
}
|
|
if (declaredTools.size === 0) {
|
|
failures.push(`${pluginId}: registers agent tools but has no contracts.tools`);
|
|
continue;
|
|
}
|
|
|
|
const missing = [...registeredNames].filter((name) => !declaredTools.has(name)).toSorted();
|
|
if (missing.length > 0) {
|
|
failures.push(`${pluginId}: missing contracts.tools for ${missing.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|