mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 05:20:44 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
363
openclaw/scripts/check-extension-plugin-sdk-boundary.mjs
Normal file
363
openclaw/scripts/check-extension-plugin-sdk-boundary.mjs
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
#!/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 {
|
||||
BUNDLED_PLUGIN_PATH_PREFIX,
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
} from "./lib/bundled-plugin-paths.mjs";
|
||||
import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mjs";
|
||||
import {
|
||||
diffInventoryEntries,
|
||||
normalizeRepoPath,
|
||||
resolveRepoSpecifier,
|
||||
visitModuleSpecifiers,
|
||||
writeLine,
|
||||
} from "./lib/guard-inventory-utils.mjs";
|
||||
import { toLine } from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const extensionsRoot = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR);
|
||||
|
||||
const MODES = new Set([
|
||||
"src-outside-plugin-sdk",
|
||||
"plugin-sdk-internal",
|
||||
"relative-outside-package",
|
||||
]);
|
||||
|
||||
const baselinePathByMode = {
|
||||
"src-outside-plugin-sdk": path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"extension-src-outside-plugin-sdk-inventory.json",
|
||||
),
|
||||
"plugin-sdk-internal": path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"extension-plugin-sdk-internal-inventory.json",
|
||||
),
|
||||
"relative-outside-package": path.join(
|
||||
repoRoot,
|
||||
"test",
|
||||
"fixtures",
|
||||
"extension-relative-outside-package-inventory.json",
|
||||
),
|
||||
};
|
||||
|
||||
let allInventoryByModePromise;
|
||||
let parsedExtensionSourceFilesPromise;
|
||||
|
||||
const ruleTextByMode = {
|
||||
"src-outside-plugin-sdk":
|
||||
"Rule: production bundled plugins must not import src/** outside src/plugin-sdk/**",
|
||||
"plugin-sdk-internal":
|
||||
"Rule: production bundled plugins must not import src/plugin-sdk-internal/**",
|
||||
"relative-outside-package":
|
||||
"Rule: production bundled plugins must not use relative imports that escape their own package root",
|
||||
};
|
||||
|
||||
function isCodeFile(fileName) {
|
||||
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(fileName);
|
||||
}
|
||||
|
||||
function isBoundaryCanaryFile(fileName) {
|
||||
return fileName.includes("__rootdir_boundary_canary__");
|
||||
}
|
||||
|
||||
async function collectExtensionSourceFiles(rootDir) {
|
||||
const out = [];
|
||||
async function walk(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "dist" || entry.name === "node_modules") {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !isCodeFile(entry.name) || isBoundaryCanaryFile(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = normalizeRepoPath(repoRoot, fullPath);
|
||||
if (classifyBundledExtensionSourcePath(relativePath).isTestLike) {
|
||||
continue;
|
||||
}
|
||||
out.push(fullPath);
|
||||
}
|
||||
}
|
||||
await walk(rootDir);
|
||||
return out.toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectParsedExtensionSourceFiles() {
|
||||
if (!parsedExtensionSourceFilesPromise) {
|
||||
parsedExtensionSourceFilesPromise = (async () => {
|
||||
const files = await collectExtensionSourceFiles(extensionsRoot);
|
||||
return await Promise.all(
|
||||
files.map(async (filePath) => {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const scriptKind =
|
||||
filePath.endsWith(".tsx") || filePath.endsWith(".jsx")
|
||||
? ts.ScriptKind.TSX
|
||||
: ts.ScriptKind.TS;
|
||||
return {
|
||||
filePath,
|
||||
sourceFile: ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
})();
|
||||
}
|
||||
return await parsedExtensionSourceFilesPromise;
|
||||
}
|
||||
|
||||
function resolveExtensionRoot(filePath) {
|
||||
const relativePath = normalizeRepoPath(repoRoot, filePath);
|
||||
const segments = relativePath.split("/");
|
||||
if (segments[0] !== BUNDLED_PLUGIN_ROOT_DIR || !segments[1]) {
|
||||
return null;
|
||||
}
|
||||
return `${segments[0]}/${segments[1]}`;
|
||||
}
|
||||
|
||||
function classifyReason(mode, kind, resolvedPath, specifier) {
|
||||
const verb =
|
||||
kind === "export"
|
||||
? "re-exports"
|
||||
: kind === "dynamic-import"
|
||||
? "dynamically imports"
|
||||
: "imports";
|
||||
if (mode === "relative-outside-package") {
|
||||
if (resolvedPath?.startsWith("src/plugin-sdk/")) {
|
||||
return `${verb} plugin-sdk via relative path; use openclaw/plugin-sdk/<subpath>`;
|
||||
}
|
||||
if (resolvedPath?.startsWith("src/")) {
|
||||
return `${verb} core src path via relative path outside the extension package`;
|
||||
}
|
||||
if (resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
||||
return `${verb} another bundled plugin via relative path outside the extension package`;
|
||||
}
|
||||
return `${verb} relative path ${specifier} outside the extension package`;
|
||||
}
|
||||
if (mode === "plugin-sdk-internal") {
|
||||
return `${verb} src/plugin-sdk-internal from an extension`;
|
||||
}
|
||||
if (resolvedPath.startsWith("src/plugin-sdk/")) {
|
||||
return `${verb} allowed plugin-sdk path`;
|
||||
}
|
||||
return `${verb} core src path outside plugin-sdk from an extension`;
|
||||
}
|
||||
|
||||
function compareEntries(left, right) {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.specifier.localeCompare(right.specifier) ||
|
||||
left.resolvedPath.localeCompare(right.resolvedPath) ||
|
||||
left.reason.localeCompare(right.reason)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldReport(mode, resolvedPath) {
|
||||
if (mode === "relative-outside-package") {
|
||||
return false;
|
||||
}
|
||||
if (!resolvedPath?.startsWith("src/")) {
|
||||
return false;
|
||||
}
|
||||
if (mode === "plugin-sdk-internal") {
|
||||
return resolvedPath.startsWith("src/plugin-sdk-internal/");
|
||||
}
|
||||
return !resolvedPath.startsWith("src/plugin-sdk/");
|
||||
}
|
||||
|
||||
function collectEntriesByModeFromSourceFile(sourceFile, filePath) {
|
||||
const entriesByMode = {
|
||||
"src-outside-plugin-sdk": [],
|
||||
"plugin-sdk-internal": [],
|
||||
"relative-outside-package": [],
|
||||
};
|
||||
const extensionRoot = resolveExtensionRoot(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
|
||||
function push(kind, specifierNode, specifier) {
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
const baseEntry = {
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
};
|
||||
|
||||
if (specifier.startsWith(".") && resolvedPath && extensionRoot) {
|
||||
if (!(resolvedPath === extensionRoot || resolvedPath.startsWith(`${extensionRoot}/`))) {
|
||||
entriesByMode["relative-outside-package"].push({
|
||||
...baseEntry,
|
||||
reason: classifyReason("relative-outside-package", kind, resolvedPath, specifier),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const mode of ["src-outside-plugin-sdk", "plugin-sdk-internal"]) {
|
||||
if (!shouldReport(mode, resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
entriesByMode[mode].push({
|
||||
...baseEntry,
|
||||
reason: classifyReason(mode, kind, resolvedPath, specifier),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
push(kind, specifierNode, specifier);
|
||||
});
|
||||
return entriesByMode;
|
||||
}
|
||||
|
||||
export async function collectExtensionPluginSdkBoundaryInventory(mode) {
|
||||
if (!MODES.has(mode)) {
|
||||
throw new Error(`Unknown mode: ${mode}`);
|
||||
}
|
||||
if (!allInventoryByModePromise) {
|
||||
allInventoryByModePromise = (async () => {
|
||||
const files = await collectParsedExtensionSourceFiles();
|
||||
const inventoryByMode = {
|
||||
"src-outside-plugin-sdk": [],
|
||||
"plugin-sdk-internal": [],
|
||||
"relative-outside-package": [],
|
||||
};
|
||||
for (const { filePath, sourceFile } of files) {
|
||||
const entriesByMode = collectEntriesByModeFromSourceFile(sourceFile, filePath);
|
||||
for (const inventoryMode of MODES) {
|
||||
inventoryByMode[inventoryMode].push(...entriesByMode[inventoryMode]);
|
||||
}
|
||||
}
|
||||
for (const inventoryMode of MODES) {
|
||||
inventoryByMode[inventoryMode] = inventoryByMode[inventoryMode].toSorted(compareEntries);
|
||||
}
|
||||
return inventoryByMode;
|
||||
})();
|
||||
}
|
||||
const inventoryByMode = await allInventoryByModePromise;
|
||||
return inventoryByMode[mode];
|
||||
}
|
||||
|
||||
export async function readExpectedInventory(mode) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8"));
|
||||
} catch (error) {
|
||||
if (
|
||||
(mode === "plugin-sdk-internal" ||
|
||||
mode === "src-outside-plugin-sdk" ||
|
||||
mode === "relative-outside-package") &&
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function diffInventory(expected, actual) {
|
||||
return diffInventoryEntries(expected, actual, compareEntries);
|
||||
}
|
||||
|
||||
function formatInventoryHuman(mode, inventory) {
|
||||
const lines = [ruleTextByMode[mode]];
|
||||
if (inventory.length === 0) {
|
||||
lines.push("No extension plugin-sdk boundary violations found.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
lines.push("Extension boundary inventory:");
|
||||
let activeFile = "";
|
||||
for (const entry of inventory) {
|
||||
if (entry.file !== activeFile) {
|
||||
activeFile = entry.file;
|
||||
lines.push(activeFile);
|
||||
}
|
||||
lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`);
|
||||
lines.push(` specifier: ${entry.specifier}`);
|
||||
lines.push(` resolved: ${entry.resolvedPath}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function runExtensionPluginSdkBoundaryCheck(argv = process.argv.slice(2), io) {
|
||||
const streams = io ?? { stdout: process.stdout, stderr: process.stderr };
|
||||
const json = argv.includes("--json");
|
||||
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
|
||||
const mode = modeArg?.slice("--mode=".length) ?? "src-outside-plugin-sdk";
|
||||
if (!MODES.has(mode)) {
|
||||
throw new Error(`Unknown mode: ${mode}`);
|
||||
}
|
||||
|
||||
const actual = await collectExtensionPluginSdkBoundaryInventory(mode);
|
||||
if (json) {
|
||||
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
writeLine(streams.stdout, formatInventoryHuman(mode, actual));
|
||||
if (mode === "relative-outside-package") {
|
||||
if (actual.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
writeLine(
|
||||
streams.stderr,
|
||||
`Relative outside-package violations found (${actual.length}); this mode no longer uses a baseline.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const expected = await readExpectedInventory(mode);
|
||||
const diff = diffInventory(expected, actual);
|
||||
if (diff.missing.length === 0 && diff.unexpected.length === 0) {
|
||||
writeLine(streams.stdout, `Baseline matches (${actual.length} entries).`);
|
||||
return 0;
|
||||
}
|
||||
if (diff.missing.length > 0) {
|
||||
writeLine(streams.stderr, `Missing baseline entries (${diff.missing.length}):`);
|
||||
for (const entry of diff.missing) {
|
||||
writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`);
|
||||
}
|
||||
}
|
||||
if (diff.unexpected.length > 0) {
|
||||
writeLine(streams.stderr, `Unexpected inventory entries (${diff.unexpected.length}):`);
|
||||
for (const entry of diff.unexpected) {
|
||||
writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), io) {
|
||||
const exitCode = await runExtensionPluginSdkBoundaryCheck(argv, io);
|
||||
if (!io) {
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue