Compare commits
2 Commits
main
...
codex/plug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70265fcc7e | ||
|
|
1f11ff2eab |
@ -213,6 +213,27 @@ Notes:
|
|||||||
|
|
||||||
Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
|
Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
|
||||||
|
|
||||||
|
## Boundary ratchet
|
||||||
|
|
||||||
|
Bundled plugins should generally target the same public surfaces as external
|
||||||
|
plugins: `openclaw/plugin-sdk/*`, `openclaw/extension-api`, and injected
|
||||||
|
runtime capabilities. This keeps bundled plugins moving toward the same stable
|
||||||
|
boundary the npm-installed plugin ecosystem depends on.
|
||||||
|
|
||||||
|
The current repo is still transitional. `openclaw/plugin-sdk/compat`,
|
||||||
|
`plugin-sdk-internal`, and direct `src/**` imports still exist in some bundled
|
||||||
|
plugins. The default expectation is:
|
||||||
|
|
||||||
|
- prefer public SDK/runtime surfaces first
|
||||||
|
- use `openclaw/plugin-sdk/compat` only as a temporary bundled-only escape hatch
|
||||||
|
- treat `plugin-sdk-internal` and direct core imports as non-default privileged
|
||||||
|
access
|
||||||
|
|
||||||
|
Search providers are a good example of why this matters: moving code from core
|
||||||
|
into `extensions/` is not enough if provider-specific ownership still leaks back
|
||||||
|
into core. Boundary checks should ratchet bundled plugins toward public surfaces
|
||||||
|
without requiring an all-at-once migration.
|
||||||
|
|
||||||
## Implemented channel-owned seams
|
## Implemented channel-owned seams
|
||||||
|
|
||||||
Recent refactor work widened the channel plugin contract so core can stop owning
|
Recent refactor work widened the channel plugin contract so core can stop owning
|
||||||
|
|||||||
@ -248,7 +248,7 @@
|
|||||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true",
|
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true",
|
||||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
|
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
|
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:boundary-ratchet && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
|
||||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
|
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
|
||||||
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
||||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||||
@ -300,6 +300,7 @@
|
|||||||
"lint:docs": "pnpm dlx markdownlint-cli2",
|
"lint:docs": "pnpm dlx markdownlint-cli2",
|
||||||
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
||||||
|
"lint:plugins:boundary-ratchet": "node scripts/check-plugin-boundary-ratchet.mjs",
|
||||||
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.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: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)",
|
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||||
|
|||||||
8466
scripts/baselines/plugin-boundary-ratchet.json
Normal file
8466
scripts/baselines/plugin-boundary-ratchet.json
Normal file
File diff suppressed because it is too large
Load Diff
384
scripts/check-plugin-boundary-ratchet.mjs
Normal file
384
scripts/check-plugin-boundary-ratchet.mjs
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
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 baselinePath = path.join(repoRoot, "scripts", "baselines", "plugin-boundary-ratchet.json");
|
||||||
|
|
||||||
|
const sourceFilePattern = /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u;
|
||||||
|
|
||||||
|
function isRelativeOrAbsoluteSpecifier(specifier) {
|
||||||
|
return specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("file:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForCompare(filePath) {
|
||||||
|
return filePath.replaceAll("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(parentPath, childPath) {
|
||||||
|
const relative = path.relative(parentPath, childPath);
|
||||||
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImportPath(filePath, specifier) {
|
||||||
|
if (specifier.startsWith("file:")) {
|
||||||
|
try {
|
||||||
|
return new URL(specifier);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path.resolve(path.dirname(filePath), specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePluginRoot(filePath, repo = repoRoot) {
|
||||||
|
const relative = path.relative(path.join(repo, "extensions"), filePath);
|
||||||
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [pluginId] = normalizeForCompare(relative).split("/");
|
||||||
|
if (!pluginId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(repo, "extensions", pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyPluginBoundaryImport(specifier, filePath, options = {}) {
|
||||||
|
const repo = options.repoRoot ?? repoRoot;
|
||||||
|
const normalizedSpecifier = specifier.trim();
|
||||||
|
if (!normalizedSpecifier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSpecifier === "openclaw/extension-api") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSpecifier === "openclaw/plugin-sdk") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSpecifier === "openclaw/plugin-sdk/compat") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSpecifier.startsWith("openclaw/plugin-sdk/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedSpecifier === "openclaw/plugin-sdk-internal" ||
|
||||||
|
normalizedSpecifier.startsWith("openclaw/plugin-sdk-internal/")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "plugin-sdk-internal",
|
||||||
|
reason: "imports non-public plugin-sdk-internal surface",
|
||||||
|
preferredReplacement: "Use openclaw/plugin-sdk/* or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRelativeOrAbsoluteSpecifier(normalizedSpecifier)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveImportPath(filePath, normalizedSpecifier);
|
||||||
|
const resolvedPath =
|
||||||
|
resolved instanceof URL
|
||||||
|
? path.resolve(resolved.pathname)
|
||||||
|
: resolved
|
||||||
|
? path.resolve(resolved)
|
||||||
|
: null;
|
||||||
|
if (!resolvedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importerPluginRoot = resolvePluginRoot(filePath, repo);
|
||||||
|
if (importerPluginRoot && isPathInside(path.join(repo, "extensions"), resolvedPath)) {
|
||||||
|
if (!isPathInside(importerPluginRoot, resolvedPath)) {
|
||||||
|
return {
|
||||||
|
kind: "cross-extension",
|
||||||
|
reason: "reaches into another extension via a relative import",
|
||||||
|
preferredReplacement:
|
||||||
|
"Keep relative imports within the same plugin root, or expose a public surface via openclaw/plugin-sdk/*, openclaw/extension-api, or a dedicated shared package.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalSdkRoot = path.join(repo, "src", "plugin-sdk-internal");
|
||||||
|
if (isPathInside(internalSdkRoot, resolvedPath)) {
|
||||||
|
return {
|
||||||
|
kind: "plugin-sdk-internal",
|
||||||
|
reason: "reaches into non-public plugin-sdk-internal implementation",
|
||||||
|
preferredReplacement: "Use openclaw/plugin-sdk/* or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreSrcRoot = path.join(repo, "src");
|
||||||
|
if (isPathInside(coreSrcRoot, resolvedPath)) {
|
||||||
|
return {
|
||||||
|
kind: "core-src",
|
||||||
|
reason: "reaches into core src/** from an extension",
|
||||||
|
preferredReplacement:
|
||||||
|
"Use openclaw/plugin-sdk/*, openclaw/extension-api, or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportLikeSpecifiers(sourceFile) {
|
||||||
|
const specifiers = [];
|
||||||
|
|
||||||
|
const push = (node, specifierNode) => {
|
||||||
|
if (!ts.isStringLiteralLike(specifierNode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
specifiers.push({
|
||||||
|
specifier: specifierNode.text,
|
||||||
|
line: toLine(sourceFile, specifierNode),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const visit = (node) => {
|
||||||
|
if (ts.isImportDeclaration(node) && node.moduleSpecifier) {
|
||||||
|
push(node, node.moduleSpecifier);
|
||||||
|
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||||
|
push(node, node.moduleSpecifier);
|
||||||
|
} else if (
|
||||||
|
ts.isCallExpression(node) &&
|
||||||
|
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||||
|
node.arguments.length > 0
|
||||||
|
) {
|
||||||
|
push(node, node.arguments[0]);
|
||||||
|
} else if (
|
||||||
|
ts.isCallExpression(node) &&
|
||||||
|
node.arguments.length > 0 &&
|
||||||
|
ts.isStringLiteralLike(node.arguments[0]) &&
|
||||||
|
((ts.isIdentifier(node.expression) && node.expression.text === "require") ||
|
||||||
|
(ts.isPropertyAccessExpression(node.expression) &&
|
||||||
|
((ts.isIdentifier(node.expression.expression) &&
|
||||||
|
node.expression.expression.text === "vi") ||
|
||||||
|
(ts.isIdentifier(node.expression.expression) &&
|
||||||
|
node.expression.expression.text === "jest")) &&
|
||||||
|
node.expression.name.text === "mock"))
|
||||||
|
) {
|
||||||
|
push(node, node.arguments[0]);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(sourceFile);
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPluginBoundaryViolations(content, filePath, options = {}) {
|
||||||
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
||||||
|
const violations = [];
|
||||||
|
for (const entry of getImportLikeSpecifiers(sourceFile)) {
|
||||||
|
const classification = classifyPluginBoundaryImport(entry.specifier, filePath, options);
|
||||||
|
if (!classification) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
violations.push({
|
||||||
|
line: entry.line,
|
||||||
|
specifier: entry.specifier,
|
||||||
|
kind: classification.kind,
|
||||||
|
reason: classification.reason,
|
||||||
|
preferredReplacement: classification.preferredReplacement,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSourceFiles(rootDir) {
|
||||||
|
const files = [];
|
||||||
|
const stack = [rootDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (["node_modules", "dist", ".git", "coverage"].includes(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack.push(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile() || !sourceFilePattern.test(entry.name) || fullPath.endsWith(".d.ts")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files.toSorted();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectBundledPluginSourceFiles(repo = repoRoot) {
|
||||||
|
const entries = await fs.readdir(path.join(repo, "extensions"), { withFileTypes: true });
|
||||||
|
const filesToCheck = new Set();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rootDir = path.join(repo, "extensions", entry.name);
|
||||||
|
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
|
||||||
|
try {
|
||||||
|
await fs.access(manifestPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entrySource = path.join(rootDir, "index.ts");
|
||||||
|
try {
|
||||||
|
await fs.access(entrySource);
|
||||||
|
filesToCheck.add(entrySource);
|
||||||
|
} catch {
|
||||||
|
// Some plugins may be source-only under src/ without a root index.ts.
|
||||||
|
}
|
||||||
|
for (const srcFile of await collectSourceFiles(rootDir)) {
|
||||||
|
filesToCheck.add(srcFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sharedFile of await collectSourceFiles(path.join(repo, "extensions", "shared"))) {
|
||||||
|
filesToCheck.add(sharedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...filesToCheck].toSorted((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBaselineKey(entry) {
|
||||||
|
return `${normalizeForCompare(entry.path)}::${entry.specifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareViolationBaseline(current, baseline) {
|
||||||
|
const baselineKeys = new Set(baseline.map(toBaselineKey));
|
||||||
|
const currentKeys = new Set(current.map(toBaselineKey));
|
||||||
|
|
||||||
|
const newViolations = current.filter((entry) => !baselineKeys.has(toBaselineKey(entry)));
|
||||||
|
const resolvedViolations = baseline.filter((entry) => !currentKeys.has(toBaselineKey(entry)));
|
||||||
|
|
||||||
|
return { newViolations, resolvedViolations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadViolationBaseline(filePath = baselinePath) {
|
||||||
|
const raw = await fs.readFile(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error(`Baseline file must be an array: ${filePath}`);
|
||||||
|
}
|
||||||
|
return parsed.map((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
throw new Error(`Baseline entry #${index + 1} must be an object.`);
|
||||||
|
}
|
||||||
|
if (typeof entry.path !== "string" || typeof entry.specifier !== "string") {
|
||||||
|
throw new Error(`Baseline entry #${index + 1} must include string path and specifier.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
specifier: entry.specifier,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectCurrentViolations(options = {}) {
|
||||||
|
const repo = options.repoRoot ?? repoRoot;
|
||||||
|
const files = await collectBundledPluginSourceFiles(repo);
|
||||||
|
const violations = [];
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const content = await fs.readFile(filePath, "utf8");
|
||||||
|
const fileViolations = findPluginBoundaryViolations(content, filePath, { repoRoot: repo });
|
||||||
|
for (const violation of fileViolations) {
|
||||||
|
violations.push({
|
||||||
|
path: path.relative(repo, filePath),
|
||||||
|
specifier: violation.specifier,
|
||||||
|
line: violation.line,
|
||||||
|
kind: violation.kind,
|
||||||
|
reason: violation.reason,
|
||||||
|
preferredReplacement: violation.preferredReplacement,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = [...new Map(violations.map((entry) => [toBaselineKey(entry), entry])).values()];
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
violations: deduped.toSorted((left, right) => {
|
||||||
|
const pathCompare = left.path.localeCompare(right.path);
|
||||||
|
if (pathCompare !== 0) {
|
||||||
|
return pathCompare;
|
||||||
|
}
|
||||||
|
return left.specifier.localeCompare(right.specifier);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printViolations(header, violations) {
|
||||||
|
if (violations.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(header);
|
||||||
|
for (const violation of violations) {
|
||||||
|
console.error(`- ${violation.path}:${violation.line}`);
|
||||||
|
console.error(` import: ${JSON.stringify(violation.specifier)}`);
|
||||||
|
console.error(` why: ${violation.reason}`);
|
||||||
|
console.error(` prefer: ${violation.preferredReplacement}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { files, violations: currentViolations } = await collectCurrentViolations({ repoRoot });
|
||||||
|
const baseline = await loadViolationBaseline();
|
||||||
|
const { newViolations, resolvedViolations } = compareViolationBaseline(
|
||||||
|
currentViolations,
|
||||||
|
baseline,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newViolations.length > 0) {
|
||||||
|
printViolations(
|
||||||
|
"New extension boundary violations found. Bundled plugins should generally use public plugin SDK/runtime surfaces.",
|
||||||
|
newViolations,
|
||||||
|
);
|
||||||
|
if (resolvedViolations.length > 0) {
|
||||||
|
console.error("");
|
||||||
|
console.error(
|
||||||
|
`Note: ${resolvedViolations.length} baseline violation(s) are already resolved. While fixing the above, also remove them from ${path.relative(repoRoot, baselinePath)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
console.error(
|
||||||
|
"Allowed for now: openclaw/plugin-sdk/*, openclaw/plugin-sdk/compat, and openclaw/extension-api.",
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Reducing existing baseline entries is encouraged; only new violations should fail this ratchet.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedViolations.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`OK: no new extension boundary violations (${files.length} files checked). ${resolvedViolations.length} baseline violation(s) are now gone; remove them from ${path.relative(repoRoot, baselinePath)} when convenient.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OK: no new extension boundary violations (${files.length} files checked).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
runAsScript(import.meta.url, main);
|
||||||
173
test/scripts/check-plugin-boundary-ratchet.test.ts
Normal file
173
test/scripts/check-plugin-boundary-ratchet.test.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyPluginBoundaryImport,
|
||||||
|
compareViolationBaseline,
|
||||||
|
findPluginBoundaryViolations,
|
||||||
|
toBaselineKey,
|
||||||
|
} from "../../scripts/check-plugin-boundary-ratchet.mjs";
|
||||||
|
|
||||||
|
const repoRoot = "/repo";
|
||||||
|
const extensionFile = "/repo/extensions/example/src/index.ts";
|
||||||
|
|
||||||
|
describe("check-plugin-boundary-ratchet", () => {
|
||||||
|
it("allows public plugin-sdk imports", () => {
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("openclaw/plugin-sdk/discord", extensionFile, { repoRoot }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("openclaw/plugin-sdk", extensionFile, { repoRoot }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows compat for now", () => {
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("openclaw/plugin-sdk/compat", extensionFile, { repoRoot }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects plugin-sdk-internal imports", () => {
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("../../../src/plugin-sdk-internal/discord.js", extensionFile, {
|
||||||
|
repoRoot,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "plugin-sdk-internal",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reject same-plugin files that merely contain plugin-sdk-internal in the filename", () => {
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("./plugin-sdk-internal-fixture.js", extensionFile, { repoRoot }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects direct core src imports", () => {
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport(
|
||||||
|
"../../src/config/config.js",
|
||||||
|
"/repo/extensions/example/index.ts",
|
||||||
|
{
|
||||||
|
repoRoot,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "core-src",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores same-plugin relative imports", () => {
|
||||||
|
expect(classifyPluginBoundaryImport("./helpers.js", extensionFile, { repoRoot })).toBeNull();
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("../shared/util.js", extensionFile, { repoRoot }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cross-extension relative imports", () => {
|
||||||
|
expect(
|
||||||
|
classifyPluginBoundaryImport("../../other-plugin/src/helper.js", extensionFile, { repoRoot }),
|
||||||
|
).toMatchObject({
|
||||||
|
kind: "cross-extension",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds import and dynamic import violations", () => {
|
||||||
|
const source = `
|
||||||
|
import { x } from "../../../src/config/config.js";
|
||||||
|
export { y } from "../../../src/plugin-sdk-internal/discord.js";
|
||||||
|
const z = await import("../../../src/runtime.js");
|
||||||
|
`;
|
||||||
|
expect(
|
||||||
|
findPluginBoundaryViolations(source, "/repo/extensions/example/nested/file.ts", { repoRoot }),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "core-src",
|
||||||
|
line: 2,
|
||||||
|
preferredReplacement:
|
||||||
|
"Use openclaw/plugin-sdk/*, openclaw/extension-api, or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
reason: "reaches into core src/** from an extension",
|
||||||
|
specifier: "../../../src/config/config.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "plugin-sdk-internal",
|
||||||
|
line: 3,
|
||||||
|
preferredReplacement:
|
||||||
|
"Use openclaw/plugin-sdk/* or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
reason: "reaches into non-public plugin-sdk-internal implementation",
|
||||||
|
specifier: "../../../src/plugin-sdk-internal/discord.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "core-src",
|
||||||
|
line: 4,
|
||||||
|
preferredReplacement:
|
||||||
|
"Use openclaw/plugin-sdk/*, openclaw/extension-api, or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
reason: "reaches into core src/** from an extension",
|
||||||
|
specifier: "../../../src/runtime.js",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds require and test mock violations", () => {
|
||||||
|
const source = `
|
||||||
|
const x = require("../../../src/config/config.js");
|
||||||
|
vi.mock("../../../src/plugin-sdk-internal/discord.js", () => ({}));
|
||||||
|
jest.mock("../../other-plugin/src/helper.js", () => ({}));
|
||||||
|
`;
|
||||||
|
expect(
|
||||||
|
findPluginBoundaryViolations(source, "/repo/extensions/example/nested/file.test.ts", {
|
||||||
|
repoRoot,
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "core-src",
|
||||||
|
line: 2,
|
||||||
|
preferredReplacement:
|
||||||
|
"Use openclaw/plugin-sdk/*, openclaw/extension-api, or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
reason: "reaches into core src/** from an extension",
|
||||||
|
specifier: "../../../src/config/config.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "plugin-sdk-internal",
|
||||||
|
line: 3,
|
||||||
|
preferredReplacement:
|
||||||
|
"Use openclaw/plugin-sdk/* or openclaw/plugin-sdk/compat temporarily.",
|
||||||
|
reason: "reaches into non-public plugin-sdk-internal implementation",
|
||||||
|
specifier: "../../../src/plugin-sdk-internal/discord.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "cross-extension",
|
||||||
|
line: 4,
|
||||||
|
preferredReplacement:
|
||||||
|
"Keep relative imports within the same plugin root, or expose a public surface via openclaw/plugin-sdk/*, openclaw/extension-api, or a dedicated shared package.",
|
||||||
|
reason: "reaches into another extension via a relative import",
|
||||||
|
specifier: "../../other-plugin/src/helper.js",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares current violations to the baseline by path and specifier", () => {
|
||||||
|
const current = [
|
||||||
|
{ path: "extensions/a/index.ts", specifier: "../../src/config/config.js" },
|
||||||
|
{ path: "extensions/b/index.ts", specifier: "../../../src/plugin-sdk-internal/discord.js" },
|
||||||
|
];
|
||||||
|
const baseline = [
|
||||||
|
{ path: "extensions/a/index.ts", specifier: "../../src/config/config.js" },
|
||||||
|
{ path: "extensions/c/index.ts", specifier: "../../src/runtime.js" },
|
||||||
|
];
|
||||||
|
expect(compareViolationBaseline(current, baseline)).toEqual({
|
||||||
|
newViolations: [
|
||||||
|
{ path: "extensions/b/index.ts", specifier: "../../../src/plugin-sdk-internal/discord.js" },
|
||||||
|
],
|
||||||
|
resolvedViolations: [{ path: "extensions/c/index.ts", specifier: "../../src/runtime.js" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a stable baseline key", () => {
|
||||||
|
expect(
|
||||||
|
toBaselineKey({
|
||||||
|
path: path.join("extensions", "a", "index.ts"),
|
||||||
|
specifier: "../../src/config/config.js",
|
||||||
|
}),
|
||||||
|
).toBe("extensions/a/index.ts::../../src/config/config.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user