mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-21 15:01:03 +00:00
build: enforce plugin import boundaries
This commit is contained in:
@@ -274,6 +274,13 @@ Compatibility note:
|
||||
- New and migrated bundled plugins should use channel or extension-specific
|
||||
subpaths; use `core` for generic surfaces and `compat` only when broader
|
||||
shared helpers are required.
|
||||
- Plugin code under `extensions/**` must not import OpenClaw core internals
|
||||
directly. Allowed imports are:
|
||||
- files inside the same extension
|
||||
- `openclaw/plugin-sdk` and `openclaw/plugin-sdk/*`
|
||||
- Node builtins and third-party packages
|
||||
- Direct imports into `src/**`, `openclaw/src/**`, or another extension's source
|
||||
tree are treated as plugin boundary violations and rejected by repo checks.
|
||||
|
||||
## Read-only channel inspection
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
@@ -8,7 +9,6 @@ import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-a
|
||||
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
|
||||
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
|
||||
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
|
||||
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
|
||||
import { resolveDiscordChannelId } from "../targets.js";
|
||||
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Guild, User } from "@buape/carbon";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
resolveChannelMatchConfig,
|
||||
type ChannelMatchSource,
|
||||
} from "../../../../src/channels/channel-config.js";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
export type DiscordAllowList = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js";
|
||||
import { danger } from "../../../../src/globals.js";
|
||||
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
|
||||
import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js";
|
||||
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
|
||||
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
|
||||
import { processDiscordMessage } from "./message-handler.process.js";
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
|
||||
import { resolveStateDir } from "../../../../src/config/paths.js";
|
||||
import { withFileLock } from "../../../../src/infra/file-lock.js";
|
||||
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js";
|
||||
import {
|
||||
readJsonFileWithFallback,
|
||||
writeJsonFileAtomically,
|
||||
} from "../../../../src/plugin-sdk/json-store.js";
|
||||
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
|
||||
|
||||
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js";
|
||||
import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from";
|
||||
|
||||
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
|
||||
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
|
||||
export type SignalSender =
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
|
||||
import { normalizeHostname } from "../../../../src/infra/net/hostname.js";
|
||||
import type { FetchLike } from "../../../../src/media/fetch.js";
|
||||
import { fetchRemoteMedia } from "../../../../src/media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||
import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js";
|
||||
import type { SlackAttachment, SlackFile } from "../types.js";
|
||||
|
||||
function isSlackHostname(hostname: string): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
|
||||
export function isSlackChannelAllowedByPolicy(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
} from "../../../src/config/types.secrets.js";
|
||||
import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js";
|
||||
import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js";
|
||||
import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js";
|
||||
import {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import util from "node:util";
|
||||
import {
|
||||
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
|
||||
resolveAccountWithDefaultFallback,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js";
|
||||
import { isTruthyEnvValue } from "../../../src/infra/env.js";
|
||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
|
||||
import {
|
||||
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
|
||||
resolveAccountWithDefaultFallback,
|
||||
} from "../../../src/plugin-sdk/account-resolution.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import {
|
||||
listBoundAccountIds,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
@@ -15,8 +17,6 @@ import type {
|
||||
ChannelMessageActionName,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
|
||||
import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js";
|
||||
import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js";
|
||||
import { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||
@@ -7,7 +8,6 @@ import type {
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../../src/config/types.js";
|
||||
import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
|
||||
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
|
||||
import { firstDefined } from "./bot-access.js";
|
||||
|
||||
|
||||
31
package.json
31
package.json
@@ -212,10 +212,38 @@
|
||||
"types": "./dist/plugin-sdk/account-id.d.ts",
|
||||
"default": "./dist/plugin-sdk/account-id.js"
|
||||
},
|
||||
"./plugin-sdk/account-resolution": {
|
||||
"types": "./dist/plugin-sdk/account-resolution.d.ts",
|
||||
"default": "./dist/plugin-sdk/account-resolution.js"
|
||||
},
|
||||
"./plugin-sdk/allow-from": {
|
||||
"types": "./dist/plugin-sdk/allow-from.d.ts",
|
||||
"default": "./dist/plugin-sdk/allow-from.js"
|
||||
},
|
||||
"./plugin-sdk/boolean-param": {
|
||||
"types": "./dist/plugin-sdk/boolean-param.d.ts",
|
||||
"default": "./dist/plugin-sdk/boolean-param.js"
|
||||
},
|
||||
"./plugin-sdk/group-access": {
|
||||
"types": "./dist/plugin-sdk/group-access.d.ts",
|
||||
"default": "./dist/plugin-sdk/group-access.js"
|
||||
},
|
||||
"./plugin-sdk/json-store": {
|
||||
"types": "./dist/plugin-sdk/json-store.d.ts",
|
||||
"default": "./dist/plugin-sdk/json-store.js"
|
||||
},
|
||||
"./plugin-sdk/keyed-async-queue": {
|
||||
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
|
||||
"default": "./dist/plugin-sdk/keyed-async-queue.js"
|
||||
},
|
||||
"./plugin-sdk/request-url": {
|
||||
"types": "./dist/plugin-sdk/request-url.d.ts",
|
||||
"default": "./dist/plugin-sdk/request-url.js"
|
||||
},
|
||||
"./plugin-sdk/tool-send": {
|
||||
"types": "./dist/plugin-sdk/tool-send.d.ts",
|
||||
"default": "./dist/plugin-sdk/tool-send.js"
|
||||
},
|
||||
"./cli-entry": "./openclaw.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -274,7 +302,7 @@
|
||||
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
|
||||
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
|
||||
"ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
|
||||
"lint": "oxlint --type-aware",
|
||||
"lint": "oxlint --type-aware && pnpm lint:plugins:import-boundaries",
|
||||
"lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
|
||||
"lint:all": "pnpm lint && pnpm lint:swift",
|
||||
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
|
||||
@@ -282,6 +310,7 @@
|
||||
"lint:docs": "pnpm dlx markdownlint-cli2",
|
||||
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
||||
"lint:plugins:import-boundaries": "node --import tsx scripts/check-plugin-import-boundaries.ts",
|
||||
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
|
||||
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
|
||||
66
scripts/check-plugin-import-boundaries.test.ts
Normal file
66
scripts/check-plugin-import-boundaries.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findPluginImportBoundaryViolations } from "./check-plugin-import-boundaries.ts";
|
||||
|
||||
const repoRoot = "/Users/thoffman/openclaw";
|
||||
|
||||
function extensionFile(relativePath: string): string {
|
||||
return path.join(repoRoot, relativePath);
|
||||
}
|
||||
|
||||
describe("findPluginImportBoundaryViolations", () => {
|
||||
it("allows same-extension relative imports", () => {
|
||||
const violations = findPluginImportBoundaryViolations(
|
||||
'import { helper } from "../shared/helper.js";',
|
||||
extensionFile("extensions/demo/src/feature/index.ts"),
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows plugin-sdk imports", () => {
|
||||
const violations = findPluginImportBoundaryViolations(
|
||||
'import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";',
|
||||
extensionFile("extensions/demo/src/feature/index.ts"),
|
||||
);
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects direct core imports", () => {
|
||||
const violations = findPluginImportBoundaryViolations(
|
||||
'import { loadConfig } from "../../../src/config/config.js";',
|
||||
extensionFile("extensions/demo/src/feature/index.ts"),
|
||||
);
|
||||
expect(violations).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "relative_escape",
|
||||
specifier: "../../../src/config/config.js",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects cross-extension source imports", () => {
|
||||
const violations = findPluginImportBoundaryViolations(
|
||||
'import { helper } from "../../other-plugin/src/helper.js";',
|
||||
extensionFile("extensions/demo/src/feature/index.ts"),
|
||||
);
|
||||
expect(violations).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "cross_extension_import",
|
||||
specifier: "../../other-plugin/src/helper.js",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects host-internal bare imports outside the SDK", () => {
|
||||
const violations = findPluginImportBoundaryViolations(
|
||||
'import { loadConfig } from "openclaw/src/config/config.js";',
|
||||
extensionFile("extensions/demo/src/feature/index.ts"),
|
||||
);
|
||||
expect(violations).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "core_internal_import",
|
||||
specifier: "openclaw/src/config/config.js",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
289
scripts/check-plugin-import-boundaries.ts
Normal file
289
scripts/check-plugin-import-boundaries.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { runAsScript, toLine } from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const extensionsRoot = path.join(repoRoot, "extensions");
|
||||
const baselinePath = path.join(repoRoot, "scripts", "plugin-import-boundaries.baseline.json");
|
||||
const codeFileRe = /\.(?:[cm]?[jt]s|tsx|jsx)$/u;
|
||||
const ignoredDirNames = new Set(["node_modules", "dist", "coverage", ".git"]);
|
||||
const nodeBuiltinSpecifiers = new Set([
|
||||
"assert",
|
||||
"buffer",
|
||||
"child_process",
|
||||
"crypto",
|
||||
"events",
|
||||
"fs",
|
||||
"http",
|
||||
"https",
|
||||
"net",
|
||||
"os",
|
||||
"path",
|
||||
"stream",
|
||||
"timers",
|
||||
"tty",
|
||||
"url",
|
||||
"util",
|
||||
"zlib",
|
||||
]);
|
||||
|
||||
type ViolationReason =
|
||||
| "relative_escape"
|
||||
| "absolute_import"
|
||||
| "core_internal_import"
|
||||
| "cross_extension_import";
|
||||
|
||||
export type PluginImportBoundaryViolation = {
|
||||
path: string;
|
||||
line: number;
|
||||
specifier: string;
|
||||
reason: ViolationReason;
|
||||
};
|
||||
|
||||
function isCodeFile(filePath: string): boolean {
|
||||
return codeFileRe.test(filePath) && !filePath.endsWith(".d.ts");
|
||||
}
|
||||
|
||||
async function collectExtensionCodeFiles(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack = [rootDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (ignoredDirNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isCodeFile(fullPath)) {
|
||||
out.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getExtensionRoot(filePath: string): string | null {
|
||||
const relative = path.relative(extensionsRoot, filePath);
|
||||
if (relative.startsWith("..")) {
|
||||
return null;
|
||||
}
|
||||
const [extensionId] = relative.split(path.sep);
|
||||
return extensionId ? path.join(extensionsRoot, extensionId) : null;
|
||||
}
|
||||
|
||||
function normalizeSpecifier(specifier: string): string {
|
||||
return specifier.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function isNodeBuiltin(specifier: string): boolean {
|
||||
return specifier.startsWith("node:") || nodeBuiltinSpecifiers.has(specifier);
|
||||
}
|
||||
|
||||
function isAllowedBareSpecifier(specifier: string): boolean {
|
||||
if (isNodeBuiltin(specifier)) {
|
||||
return true;
|
||||
}
|
||||
if (specifier === "openclaw/plugin-sdk" || specifier.startsWith("openclaw/plugin-sdk/")) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("openclaw/")
|
||||
);
|
||||
}
|
||||
|
||||
function classifySpecifier(params: { importerPath: string; specifier: string }): {
|
||||
reason?: ViolationReason;
|
||||
} {
|
||||
const specifier = normalizeSpecifier(params.specifier);
|
||||
if (specifier === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isAllowedBareSpecifier(specifier)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (specifier.startsWith("openclaw/src/") || specifier === "openclaw/src") {
|
||||
return { reason: "core_internal_import" };
|
||||
}
|
||||
|
||||
if (specifier.startsWith("/")) {
|
||||
return { reason: "absolute_import" };
|
||||
}
|
||||
|
||||
if (!specifier.startsWith(".")) {
|
||||
return { reason: "core_internal_import" };
|
||||
}
|
||||
|
||||
const extensionRoot = getExtensionRoot(params.importerPath);
|
||||
if (!extensionRoot) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const importerDir = path.dirname(params.importerPath);
|
||||
const resolved = path.resolve(importerDir, specifier);
|
||||
const normalizedRoot = `${extensionRoot}${path.sep}`;
|
||||
const normalizedResolved = resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
|
||||
if (!(resolved === extensionRoot || normalizedResolved.startsWith(normalizedRoot))) {
|
||||
const relativeToExtensions = path.relative(extensionsRoot, resolved);
|
||||
if (!relativeToExtensions.startsWith("..")) {
|
||||
return { reason: "cross_extension_import" };
|
||||
}
|
||||
return { reason: "relative_escape" };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function collectModuleSpecifiers(
|
||||
sourceFile: ts.SourceFile,
|
||||
): Array<{ specifier: string; line: number }> {
|
||||
const specifiers: Array<{ specifier: string; line: number }> = [];
|
||||
|
||||
const maybePushSpecifier = (node: ts.StringLiteralLike) => {
|
||||
specifiers.push({ specifier: node.text, line: toLine(sourceFile, node) });
|
||||
};
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||
maybePushSpecifier(node.moduleSpecifier);
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteralLike(node.moduleSpecifier)
|
||||
) {
|
||||
maybePushSpecifier(node.moduleSpecifier);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.arguments.length > 0 &&
|
||||
ts.isStringLiteralLike(node.arguments[0])
|
||||
) {
|
||||
const firstArg = node.arguments[0];
|
||||
if (
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === "require") ||
|
||||
(ts.isPropertyAccessExpression(node.expression) &&
|
||||
ts.isIdentifier(node.expression.name) &&
|
||||
node.expression.name.text === "mock")
|
||||
) {
|
||||
maybePushSpecifier(firstArg);
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
export function findPluginImportBoundaryViolations(
|
||||
content: string,
|
||||
filePath: string,
|
||||
): PluginImportBoundaryViolation[] {
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
||||
const relativePath = path.relative(repoRoot, filePath);
|
||||
const violations: PluginImportBoundaryViolation[] = [];
|
||||
|
||||
for (const entry of collectModuleSpecifiers(sourceFile)) {
|
||||
const classified = classifySpecifier({ importerPath: filePath, specifier: entry.specifier });
|
||||
if (!classified.reason) {
|
||||
continue;
|
||||
}
|
||||
violations.push({
|
||||
path: relativePath,
|
||||
line: entry.line,
|
||||
specifier: entry.specifier,
|
||||
reason: classified.reason,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function loadBaseline(): Promise<PluginImportBoundaryViolation[]> {
|
||||
const raw = await fs.readFile(baselinePath, "utf8");
|
||||
return JSON.parse(raw) as PluginImportBoundaryViolation[];
|
||||
}
|
||||
|
||||
function sortViolations(
|
||||
violations: PluginImportBoundaryViolation[],
|
||||
): PluginImportBoundaryViolation[] {
|
||||
return [...violations].toSorted(
|
||||
(left, right) =>
|
||||
left.path.localeCompare(right.path) ||
|
||||
left.line - right.line ||
|
||||
left.specifier.localeCompare(right.specifier) ||
|
||||
left.reason.localeCompare(right.reason),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectViolations(): Promise<PluginImportBoundaryViolation[]> {
|
||||
const files = await collectExtensionCodeFiles(extensionsRoot);
|
||||
const violations: PluginImportBoundaryViolation[] = [];
|
||||
for (const filePath of files) {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
violations.push(...findPluginImportBoundaryViolations(content, filePath));
|
||||
}
|
||||
return sortViolations(violations);
|
||||
}
|
||||
|
||||
function violationKey(violation: PluginImportBoundaryViolation): string {
|
||||
return `${violation.path}:${violation.line}:${violation.reason}:${violation.specifier}`;
|
||||
}
|
||||
|
||||
async function writeBaseline(): Promise<void> {
|
||||
const violations = await collectViolations();
|
||||
await fs.writeFile(baselinePath, `${JSON.stringify(violations, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote plugin import boundary baseline (${violations.length} violations).`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.argv.includes("--write-baseline")) {
|
||||
await writeBaseline();
|
||||
return;
|
||||
}
|
||||
|
||||
const violations = await collectViolations();
|
||||
const baseline = sortViolations(await loadBaseline());
|
||||
const baselineKeys = new Set(baseline.map(violationKey));
|
||||
const violationKeys = new Set(violations.map(violationKey));
|
||||
|
||||
const newViolations = violations.filter((entry) => !baselineKeys.has(violationKey(entry)));
|
||||
const resolvedViolations = baseline.filter((entry) => !violationKeys.has(violationKey(entry)));
|
||||
|
||||
if (newViolations.length > 0) {
|
||||
console.error("New plugin import boundary violations found:");
|
||||
for (const violation of newViolations) {
|
||||
console.error(
|
||||
`- ${violation.path}:${violation.line} ${violation.reason} ${JSON.stringify(violation.specifier)}`,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"Extensions may only import same-extension files, openclaw/plugin-sdk/*, Node builtins, or third-party packages.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (resolvedViolations.length > 0) {
|
||||
console.warn(
|
||||
`Note: ${resolvedViolations.length} baseline plugin-boundary violations were removed. Re-run with --write-baseline to refresh scripts/plugin-import-boundaries.baseline.json.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`OK: no new plugin import boundary violations (${violations.length} baseline violations tracked).`,
|
||||
);
|
||||
}
|
||||
|
||||
runAsScript(import.meta.url, main);
|
||||
10034
scripts/plugin-import-boundaries.baseline.json
Normal file
10034
scripts/plugin-import-boundaries.baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import * as discordSdk from "openclaw/plugin-sdk/discord";
|
||||
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
|
||||
import * as lineSdk from "openclaw/plugin-sdk/line";
|
||||
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
|
||||
import * as requestUrlSdk from "openclaw/plugin-sdk/request-url";
|
||||
import * as signalSdk from "openclaw/plugin-sdk/signal";
|
||||
import * as slackSdk from "openclaw/plugin-sdk/slack";
|
||||
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
|
||||
@@ -99,6 +100,28 @@ describe("plugin-sdk subpath exports", () => {
|
||||
expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function");
|
||||
});
|
||||
|
||||
it("exports shared utility subpaths", async () => {
|
||||
expect(typeof requestUrlSdk.resolveRequestUrl).toBe("function");
|
||||
|
||||
const booleanParamSdk = await import("openclaw/plugin-sdk/boolean-param");
|
||||
expect(typeof booleanParamSdk.readBooleanParam).toBe("function");
|
||||
|
||||
const groupAccessSdk = await import("openclaw/plugin-sdk/group-access");
|
||||
expect(typeof groupAccessSdk.evaluateGroupRouteAccessForPolicy).toBe("function");
|
||||
|
||||
const toolSendSdk = await import("openclaw/plugin-sdk/tool-send");
|
||||
expect(typeof toolSendSdk.extractToolSend).toBe("function");
|
||||
|
||||
const accountResolutionSdk = await import("openclaw/plugin-sdk/account-resolution");
|
||||
expect(typeof accountResolutionSdk.resolveAccountWithDefaultFallback).toBe("function");
|
||||
|
||||
const allowFromSdk = await import("openclaw/plugin-sdk/allow-from");
|
||||
expect(typeof allowFromSdk.isAllowedParsedChatSender).toBe("function");
|
||||
|
||||
const jsonStoreSdk = await import("openclaw/plugin-sdk/json-store");
|
||||
expect(typeof jsonStoreSdk.readJsonFileWithFallback).toBe("function");
|
||||
});
|
||||
|
||||
it("exports acpx helpers", async () => {
|
||||
const acpxSdk = await import("openclaw/plugin-sdk/acpx");
|
||||
expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function");
|
||||
|
||||
@@ -10,6 +10,9 @@ const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const ciWorkers = isWindows ? 2 : 3;
|
||||
const pluginSdkSubpaths = [
|
||||
"account-id",
|
||||
"account-resolution",
|
||||
"allow-from",
|
||||
"boolean-param",
|
||||
"core",
|
||||
"web-search",
|
||||
"compat",
|
||||
@@ -30,7 +33,9 @@ const pluginSdkSubpaths = [
|
||||
"feishu",
|
||||
"google-gemini-cli-auth",
|
||||
"googlechat",
|
||||
"group-access",
|
||||
"irc",
|
||||
"json-store",
|
||||
"llm-task",
|
||||
"lobster",
|
||||
"matrix",
|
||||
@@ -48,11 +53,13 @@ const pluginSdkSubpaths = [
|
||||
"test-utils",
|
||||
"thread-ownership",
|
||||
"tlon",
|
||||
"tool-send",
|
||||
"twitch",
|
||||
"voice-call",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
"keyed-async-queue",
|
||||
"request-url",
|
||||
] as const;
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user