mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 13:44:03 +00:00
refactor: dedupe test and script helpers
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
#!/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 {
|
||||
collectTypeScriptInventory,
|
||||
normalizeRepoPath,
|
||||
resolveRepoSpecifier,
|
||||
visitModuleSpecifiers,
|
||||
writeLine,
|
||||
} from "./lib/guard-inventory-utils.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveSourceRoots,
|
||||
@@ -14,10 +20,6 @@ import {
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const scanRoots = resolveSourceRoots(repoRoot, ["src/plugin-sdk", "src/plugins/runtime"]);
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function compareEntries(left, right) {
|
||||
return (
|
||||
left.category.localeCompare(right.category) ||
|
||||
@@ -29,58 +31,41 @@ function compareEntries(left, right) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSpecifier(specifier, importerFile) {
|
||||
if (specifier.startsWith(".")) {
|
||||
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
|
||||
}
|
||||
if (specifier.startsWith("/")) {
|
||||
return normalizePath(specifier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pushEntry(entries, entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (!relativeFile.startsWith("src/plugin-sdk/")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
category: "plugin-sdk-extension-facade",
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
kind: "export",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: "plugin-sdk public surface re-exports extension-owned implementation",
|
||||
});
|
||||
}
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
if (kind !== "export") {
|
||||
return;
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (!resolvedPath?.startsWith("extensions/")) {
|
||||
return;
|
||||
}
|
||||
pushEntry(entries, {
|
||||
category: "plugin-sdk-extension-facade",
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: "plugin-sdk public surface re-exports extension-owned implementation",
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) {
|
||||
return [];
|
||||
}
|
||||
@@ -94,7 +79,7 @@ function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
|
||||
ts.isStringLiteral(node.argument.literal)
|
||||
) {
|
||||
const specifier = node.argument.literal.text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (
|
||||
resolvedPath &&
|
||||
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
|
||||
@@ -120,7 +105,7 @@ function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
|
||||
}
|
||||
|
||||
function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (
|
||||
!relativeFile.startsWith("src/plugin-sdk/") &&
|
||||
!relativeFile.startsWith("src/plugins/runtime/")
|
||||
@@ -210,25 +195,20 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
|
||||
|
||||
export async function collectArchitectureSmells() {
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) =>
|
||||
normalizePath(left).localeCompare(normalizePath(right)),
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
|
||||
const inventory = [];
|
||||
for (const filePath of files) {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
inventory.push(...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath));
|
||||
inventory.push(...scanRuntimeTypeImplementationSmells(sourceFile, filePath));
|
||||
inventory.push(...scanRuntimeServiceLocatorSmells(sourceFile, filePath));
|
||||
}
|
||||
|
||||
return inventory.toSorted(compareEntries);
|
||||
return await collectTypeScriptInventory({
|
||||
ts,
|
||||
files,
|
||||
compareEntries,
|
||||
collectEntries(sourceFile, filePath) {
|
||||
return [
|
||||
...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath),
|
||||
...scanRuntimeTypeImplementationSmells(sourceFile, filePath),
|
||||
...scanRuntimeServiceLocatorSmells(sourceFile, filePath),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatInventoryHuman(inventory) {
|
||||
@@ -256,10 +236,6 @@ function formatInventoryHuman(inventory) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function writeLine(stream, text) {
|
||||
stream.write(`${text}\n`);
|
||||
}
|
||||
|
||||
export async function runArchitectureSmellsCheck(argv = process.argv.slice(2), io) {
|
||||
const streams = io ?? { stdout: process.stdout, stderr: process.stderr };
|
||||
const json = argv.includes("--json");
|
||||
|
||||
@@ -4,6 +4,14 @@ import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
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, "extensions");
|
||||
@@ -47,10 +55,6 @@ const ruleTextByMode = {
|
||||
"Rule: production extensions/** must not use relative imports that escape their own extension package root",
|
||||
};
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isCodeFile(fileName) {
|
||||
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(fileName);
|
||||
}
|
||||
@@ -79,7 +83,7 @@ async function collectExtensionSourceFiles(rootDir) {
|
||||
if (!entry.isFile() || !isCodeFile(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = normalizePath(fullPath);
|
||||
const relativePath = normalizeRepoPath(repoRoot, fullPath);
|
||||
if (isTestLikeFile(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
@@ -87,7 +91,9 @@ async function collectExtensionSourceFiles(rootDir) {
|
||||
}
|
||||
}
|
||||
await walk(rootDir);
|
||||
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
||||
return out.toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectParsedExtensionSourceFiles() {
|
||||
@@ -118,22 +124,8 @@ async function collectParsedExtensionSourceFiles() {
|
||||
return await parsedExtensionSourceFilesPromise;
|
||||
}
|
||||
|
||||
function toLine(sourceFile, node) {
|
||||
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||
}
|
||||
|
||||
function resolveSpecifier(specifier, importerFile) {
|
||||
if (specifier.startsWith(".")) {
|
||||
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
|
||||
}
|
||||
if (specifier.startsWith("/")) {
|
||||
return normalizePath(specifier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExtensionRoot(filePath) {
|
||||
const relativePath = normalizePath(filePath);
|
||||
const relativePath = normalizeRepoPath(repoRoot, filePath);
|
||||
const segments = relativePath.split("/");
|
||||
if (segments[0] !== "extensions" || !segments[1]) {
|
||||
return null;
|
||||
@@ -200,11 +192,12 @@ function collectEntriesByModeFromSourceFile(sourceFile, filePath) {
|
||||
"relative-outside-package": [],
|
||||
};
|
||||
const extensionRoot = resolveExtensionRoot(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
|
||||
function push(kind, specifierNode, specifier) {
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
const baseEntry = {
|
||||
file: normalizePath(filePath),
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
kind,
|
||||
specifier,
|
||||
@@ -231,27 +224,9 @@ function collectEntriesByModeFromSourceFile(sourceFile, filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
push("dynamic-import", node.arguments[0], node.arguments[0].text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
push(kind, specifierNode, specifier);
|
||||
});
|
||||
return entriesByMode;
|
||||
}
|
||||
|
||||
@@ -303,16 +278,7 @@ export async function readExpectedInventory(mode) {
|
||||
}
|
||||
|
||||
export function diffInventory(expected, actual) {
|
||||
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
||||
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
||||
return {
|
||||
missing: expected
|
||||
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
unexpected: actual
|
||||
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
};
|
||||
return diffInventoryEntries(expected, actual, compareEntries);
|
||||
}
|
||||
|
||||
function formatInventoryHuman(mode, inventory) {
|
||||
@@ -335,10 +301,6 @@ function formatInventoryHuman(mode, inventory) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function writeLine(stream, text) {
|
||||
stream.write(`${text}\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");
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
|
||||
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
|
||||
import {
|
||||
collectCallExpressionLines,
|
||||
runAsScript,
|
||||
unwrapExpression,
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const sourceRoots = ["src/gateway", "extensions/discord/src/voice"];
|
||||
const enforcedFiles = new Set([
|
||||
@@ -16,18 +20,10 @@ const enforcedFiles = new Set([
|
||||
|
||||
export function findLegacyAgentCommandCallLines(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const lines = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = unwrapExpression(node.expression);
|
||||
if (ts.isIdentifier(callee) && callee.text === "agentCommand") {
|
||||
lines.push(toLine(sourceFile, callee));
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
return collectCallExpressionLines(ts, sourceFile, (node) => {
|
||||
const callee = unwrapExpression(node.expression);
|
||||
return ts.isIdentifier(callee) && callee.text === "agentCommand" ? callee : null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import ts from "typescript";
|
||||
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
|
||||
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
|
||||
import {
|
||||
collectCallExpressionLines,
|
||||
runAsScript,
|
||||
unwrapExpression,
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const sourceRoots = [
|
||||
"src/channels",
|
||||
@@ -50,27 +54,18 @@ function collectOsTmpdirImports(sourceFile) {
|
||||
export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const { osNamespaceOrDefault, namedTmpdir } = collectOsTmpdirImports(sourceFile);
|
||||
const lines = [];
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = unwrapExpression(node.expression);
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
callee.name.text === "tmpdir" &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
osNamespaceOrDefault.has(callee.expression.text)
|
||||
) {
|
||||
lines.push(toLine(sourceFile, callee));
|
||||
} else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) {
|
||||
lines.push(toLine(sourceFile, callee));
|
||||
}
|
||||
return collectCallExpressionLines(ts, sourceFile, (node) => {
|
||||
const callee = unwrapExpression(node.expression);
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
callee.name.text === "tmpdir" &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
osNamespaceOrDefault.has(callee.expression.text)
|
||||
) {
|
||||
return callee;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
return ts.isIdentifier(callee) && namedTmpdir.has(callee.text) ? callee : null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import ts from "typescript";
|
||||
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
|
||||
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
|
||||
import {
|
||||
collectCallExpressionLines,
|
||||
runAsScript,
|
||||
unwrapExpression,
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"];
|
||||
|
||||
@@ -65,15 +69,9 @@ function isRawFetchCall(expression) {
|
||||
|
||||
export function findRawFetchCallLines(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const lines = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && isRawFetchCall(node.expression)) {
|
||||
lines.push(toLine(sourceFile, node.expression));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
return collectCallExpressionLines(ts, sourceFile, (node) =>
|
||||
isRawFetchCall(node.expression) ? node.expression : null,
|
||||
);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import ts from "typescript";
|
||||
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
|
||||
import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
|
||||
import {
|
||||
collectCallExpressionLines,
|
||||
runAsScript,
|
||||
unwrapExpression,
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const sourceRoots = ["src", "extensions"];
|
||||
|
||||
@@ -13,15 +17,9 @@ function isDeprecatedRegisterHttpHandlerCall(expression) {
|
||||
|
||||
export function findDeprecatedRegisterHttpHandlerLines(content, fileName = "source.ts") {
|
||||
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
||||
const lines = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && isDeprecatedRegisterHttpHandlerCall(node.expression)) {
|
||||
lines.push(toLine(sourceFile, node.expression));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
return collectCallExpressionLines(ts, sourceFile, (node) =>
|
||||
isDeprecatedRegisterHttpHandlerCall(node.expression) ? node.expression : null,
|
||||
);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
collectTypeScriptInventory,
|
||||
diffInventoryEntries,
|
||||
normalizeRepoPath,
|
||||
runBaselineInventoryCheck,
|
||||
resolveRepoSpecifier,
|
||||
visitModuleSpecifiers,
|
||||
} from "./lib/guard-inventory-utils.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveSourceRoots,
|
||||
@@ -39,10 +47,6 @@ const bundledWebSearchPluginIds = new Set([
|
||||
"xai",
|
||||
]);
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function compareEntries(left, right) {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
@@ -53,16 +57,6 @@ function compareEntries(left, right) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSpecifier(specifier, importerFile) {
|
||||
if (specifier.startsWith(".")) {
|
||||
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
|
||||
}
|
||||
if (specifier.startsWith("/")) {
|
||||
return normalizePath(specifier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyResolvedExtensionReason(kind, resolvedPath) {
|
||||
const verb =
|
||||
kind === "export"
|
||||
@@ -85,67 +79,27 @@ function pushEntry(entries, entry) {
|
||||
|
||||
function scanImportBoundaryViolations(sourceFile, filePath) {
|
||||
const entries = [];
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
file: normalizePath(filePath),
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
kind: "import",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason("import", resolvedPath),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
const specifier = node.moduleSpecifier.text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
file: normalizePath(filePath),
|
||||
line: toLine(sourceFile, node.moduleSpecifier),
|
||||
kind: "export",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason("export", resolvedPath),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
const specifier = node.arguments[0].text;
|
||||
const resolvedPath = resolveSpecifier(specifier, filePath);
|
||||
if (resolvedPath?.startsWith("extensions/")) {
|
||||
pushEntry(entries, {
|
||||
file: normalizePath(filePath),
|
||||
line: toLine(sourceFile, node.arguments[0]),
|
||||
kind: "dynamic-import",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason("dynamic-import", resolvedPath),
|
||||
});
|
||||
}
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (!resolvedPath?.startsWith("extensions/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
pushEntry(entries, {
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason(kind, resolvedPath),
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanWebSearchRegistrySmells(sourceFile, filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (relativeFile !== "src/plugins/web-search-providers.ts") {
|
||||
return [];
|
||||
}
|
||||
@@ -195,7 +149,7 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) {
|
||||
}
|
||||
|
||||
function shouldSkipFile(filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
return (
|
||||
relativeFile === "src/plugins/bundled-web-search-registry.ts" ||
|
||||
relativeFile.startsWith("src/plugins/contracts/") ||
|
||||
@@ -211,23 +165,20 @@ export async function collectPluginExtensionImportBoundaryInventory() {
|
||||
cachedInventoryPromise = (async () => {
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots))
|
||||
.filter((filePath) => !shouldSkipFile(filePath))
|
||||
.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
||||
|
||||
const inventory = [];
|
||||
for (const filePath of files) {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
.toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
inventory.push(...scanImportBoundaryViolations(sourceFile, filePath));
|
||||
inventory.push(...scanWebSearchRegistrySmells(sourceFile, filePath));
|
||||
}
|
||||
|
||||
return inventory.toSorted(compareEntries);
|
||||
return await collectTypeScriptInventory({
|
||||
ts,
|
||||
files,
|
||||
compareEntries,
|
||||
collectEntries(sourceFile, filePath) {
|
||||
return [
|
||||
...scanImportBoundaryViolations(sourceFile, filePath),
|
||||
...scanWebSearchRegistrySmells(sourceFile, filePath),
|
||||
];
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -255,16 +206,7 @@ export async function readExpectedInventory() {
|
||||
}
|
||||
|
||||
export function diffInventory(expected, actual) {
|
||||
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
||||
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
||||
return {
|
||||
missing: expected
|
||||
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
unexpected: actual
|
||||
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
};
|
||||
return diffInventoryEntries(expected, actual, compareEntries);
|
||||
}
|
||||
|
||||
function formatInventoryHuman(inventory) {
|
||||
@@ -293,48 +235,16 @@ function formatEntry(entry) {
|
||||
return `${entry.file}:${entry.line} [${entry.kind}] ${entry.reason} (${entry.specifier} -> ${entry.resolvedPath})`;
|
||||
}
|
||||
|
||||
function writeLine(stream, text) {
|
||||
stream.write(`${text}\n`);
|
||||
}
|
||||
|
||||
export async function runPluginExtensionImportBoundaryCheck(argv = process.argv.slice(2), io) {
|
||||
const streams = io ?? { stdout: process.stdout, stderr: process.stderr };
|
||||
const json = argv.includes("--json");
|
||||
const actual = await collectPluginExtensionImportBoundaryInventory();
|
||||
const expected = await readExpectedInventory();
|
||||
const { missing, unexpected } = diffInventory(expected, actual);
|
||||
const matchesBaseline = missing.length === 0 && unexpected.length === 0;
|
||||
|
||||
if (json) {
|
||||
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
|
||||
} else {
|
||||
writeLine(streams.stdout, formatInventoryHuman(actual));
|
||||
writeLine(
|
||||
streams.stdout,
|
||||
matchesBaseline
|
||||
? `Baseline matches (${actual.length} entries).`
|
||||
: `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`,
|
||||
);
|
||||
if (!matchesBaseline) {
|
||||
if (unexpected.length > 0) {
|
||||
writeLine(streams.stderr, "Unexpected entries:");
|
||||
for (const entry of unexpected) {
|
||||
writeLine(streams.stderr, `- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
writeLine(streams.stderr, "Missing baseline entries:");
|
||||
for (const entry of missing) {
|
||||
writeLine(streams.stderr, `- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesBaseline) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
return await runBaselineInventoryCheck({
|
||||
argv,
|
||||
io,
|
||||
collectActual: collectPluginExtensionImportBoundaryInventory,
|
||||
readExpected: readExpectedInventory,
|
||||
diffInventory,
|
||||
formatInventoryHuman,
|
||||
formatEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), io) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { normalizeRepoPath, visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveSourceRoots,
|
||||
@@ -29,10 +30,6 @@ function readEntrypoints() {
|
||||
return new Set(entrypoints.filter((entry) => entry !== "index"));
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function parsePluginSdkSubpath(specifier) {
|
||||
if (!specifier.startsWith("openclaw/plugin-sdk/")) {
|
||||
return null;
|
||||
@@ -55,7 +52,8 @@ async function collectViolations() {
|
||||
const entrypoints = readEntrypoints();
|
||||
const exports = readPackageExports();
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots, { includeTests: true })).toSorted(
|
||||
(left, right) => normalizePath(left).localeCompare(normalizePath(right)),
|
||||
(left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
const violations = [];
|
||||
|
||||
@@ -87,7 +85,7 @@ async function collectViolations() {
|
||||
}
|
||||
|
||||
violations.push({
|
||||
file: normalizePath(filePath),
|
||||
file: normalizeRepoPath(repoRoot, filePath),
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
kind,
|
||||
specifier,
|
||||
@@ -96,27 +94,9 @@ async function collectViolations() {
|
||||
});
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
push("dynamic-import", node.arguments[0], node.arguments[0].text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
push(kind, specifierNode, specifier);
|
||||
});
|
||||
}
|
||||
|
||||
return violations.toSorted(compareEntries);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
diffInventoryEntries,
|
||||
normalizeRepoPath,
|
||||
runBaselineInventoryCheck,
|
||||
} from "./lib/guard-inventory-utils.mjs";
|
||||
import { runAsScript } from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -62,10 +67,6 @@ const ignoredFiles = new Set([
|
||||
|
||||
let webSearchProviderInventoryPromise;
|
||||
|
||||
function normalizeRelativePath(filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
async function walkFiles(rootDir) {
|
||||
const out = [];
|
||||
let entries = [];
|
||||
@@ -195,11 +196,11 @@ export async function collectWebSearchProviderBoundaryInventory() {
|
||||
)
|
||||
.flat()
|
||||
.toSorted((left, right) =>
|
||||
normalizeRelativePath(left).localeCompare(normalizeRelativePath(right)),
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativeFile = normalizeRelativePath(filePath);
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (ignoredFiles.has(relativeFile) || relativeFile.includes(".test.")) {
|
||||
continue;
|
||||
}
|
||||
@@ -232,14 +233,7 @@ export async function readExpectedInventory() {
|
||||
}
|
||||
|
||||
export function diffInventory(expected, actual) {
|
||||
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
||||
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
||||
const missing = expected.filter((entry) => !actualKeys.has(JSON.stringify(entry)));
|
||||
const unexpected = actual.filter((entry) => !expectedKeys.has(JSON.stringify(entry)));
|
||||
return {
|
||||
missing: missing.toSorted(compareInventoryEntries),
|
||||
unexpected: unexpected.toSorted(compareInventoryEntries),
|
||||
};
|
||||
return diffInventoryEntries(expected, actual, compareInventoryEntries);
|
||||
}
|
||||
|
||||
function formatInventoryHuman(inventory) {
|
||||
@@ -262,48 +256,16 @@ function formatEntry(entry) {
|
||||
return `${entry.provider} ${entry.file}:${entry.line} ${entry.reason}`;
|
||||
}
|
||||
|
||||
function writeLine(stream, text) {
|
||||
stream.write(`${text}\n`);
|
||||
}
|
||||
|
||||
export async function runWebSearchProviderBoundaryCheck(argv = process.argv.slice(2), io) {
|
||||
const streams = io ?? { stdout: process.stdout, stderr: process.stderr };
|
||||
const json = argv.includes("--json");
|
||||
const actual = await collectWebSearchProviderBoundaryInventory();
|
||||
const expected = await readExpectedInventory();
|
||||
const { missing, unexpected } = diffInventory(expected, actual);
|
||||
const matchesBaseline = missing.length === 0 && unexpected.length === 0;
|
||||
|
||||
if (json) {
|
||||
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
|
||||
} else {
|
||||
writeLine(streams.stdout, formatInventoryHuman(actual));
|
||||
writeLine(
|
||||
streams.stdout,
|
||||
matchesBaseline
|
||||
? `Baseline matches (${actual.length} entries).`
|
||||
: `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`,
|
||||
);
|
||||
if (!matchesBaseline) {
|
||||
if (unexpected.length > 0) {
|
||||
writeLine(streams.stderr, "Unexpected entries:");
|
||||
for (const entry of unexpected) {
|
||||
writeLine(streams.stderr, `- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
writeLine(streams.stderr, "Missing baseline entries:");
|
||||
for (const entry of missing) {
|
||||
writeLine(streams.stderr, `- ${formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesBaseline) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
return await runBaselineInventoryCheck({
|
||||
argv,
|
||||
io,
|
||||
collectActual: collectWebSearchProviderBoundaryInventory,
|
||||
readExpected: readExpectedInventory,
|
||||
diffInventory,
|
||||
formatInventoryHuman,
|
||||
formatEntry,
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), io) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { collectBundledPluginSources } from "./lib/bundled-plugin-source-utils.mjs";
|
||||
import { formatGeneratedModule } from "./lib/format-generated-module.mjs";
|
||||
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
||||
import { reportGeneratedOutputCli, writeGeneratedOutput } from "./lib/generated-output-utils.mjs";
|
||||
|
||||
const GENERATED_BY = "scripts/generate-bundled-plugin-metadata.mjs";
|
||||
const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-plugin-metadata.generated.ts";
|
||||
@@ -16,14 +15,6 @@ const CANONICAL_PACKAGE_ID_ALIASES = {
|
||||
"vllm-provider": "vllm",
|
||||
};
|
||||
|
||||
function readIfExists(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteEntryToBuiltPath(entry) {
|
||||
if (typeof entry !== "string" || entry.trim().length === 0) {
|
||||
return undefined;
|
||||
@@ -136,30 +127,14 @@ function formatTypeScriptModule(source, { outputPath }) {
|
||||
|
||||
export function collectBundledPluginMetadata(params = {}) {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const extensionsRoot = path.join(repoRoot, "extensions");
|
||||
if (!fs.existsSync(extensionsRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginDir = path.join(extensionsRoot, dirent.name);
|
||||
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
|
||||
const packageJsonPath = path.join(pluginDir, "package.json");
|
||||
if (!fs.existsSync(manifestPath) || !fs.existsSync(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = normalizePluginManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
||||
for (const source of collectBundledPluginSources({ repoRoot, requirePackageJson: true })) {
|
||||
const manifest = normalizePluginManifest(source.manifest);
|
||||
if (!manifest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
const packageJson = source.packageJson;
|
||||
const packageManifest = normalizePackageManifest(packageJson);
|
||||
const extensions = Array.isArray(packageManifest?.extensions)
|
||||
? packageManifest.extensions.filter((entry) => typeof entry === "string" && entry.trim())
|
||||
@@ -183,7 +158,7 @@ export function collectBundledPluginMetadata(params = {}) {
|
||||
: undefined;
|
||||
|
||||
entries.push({
|
||||
dirName: dirent.name,
|
||||
dirName: source.dirName,
|
||||
idHint: deriveIdHint({
|
||||
filePath: sourceEntry,
|
||||
packageName: typeof packageJson.name === "string" ? packageJson.name : undefined,
|
||||
@@ -225,39 +200,16 @@ export function writeBundledPluginMetadataModule(params = {}) {
|
||||
renderBundledPluginMetadataModule(collectBundledPluginMetadata({ repoRoot })),
|
||||
{ outputPath },
|
||||
);
|
||||
const current = readIfExists(outputPath);
|
||||
const changed = current !== next;
|
||||
|
||||
if (params.check) {
|
||||
return {
|
||||
changed,
|
||||
wrote: false,
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changed,
|
||||
wrote: writeTextFileIfChanged(outputPath, next),
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
const result = writeBundledPluginMetadataModule({
|
||||
check: process.argv.includes("--check"),
|
||||
return writeGeneratedOutput({
|
||||
repoRoot,
|
||||
outputPath: params.outputPath ?? DEFAULT_OUTPUT_PATH,
|
||||
next,
|
||||
check: params.check,
|
||||
});
|
||||
|
||||
if (result.changed) {
|
||||
if (process.argv.includes("--check")) {
|
||||
console.error(
|
||||
`[bundled-plugin-metadata] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`[bundled-plugin-metadata] wrote ${path.relative(process.cwd(), result.outputPath)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportGeneratedOutputCli({
|
||||
importMetaUrl: import.meta.url,
|
||||
label: "bundled-plugin-metadata",
|
||||
run: ({ check }) => writeBundledPluginMetadataModule({ check }),
|
||||
});
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
||||
import { collectBundledPluginSources } from "./lib/bundled-plugin-source-utils.mjs";
|
||||
import { reportGeneratedOutputCli, writeGeneratedOutput } from "./lib/generated-output-utils.mjs";
|
||||
|
||||
const GENERATED_BY = "scripts/generate-bundled-provider-auth-env-vars.mjs";
|
||||
const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-provider-auth-env-vars.generated.ts";
|
||||
|
||||
function readIfExists(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderAuthEnvVars(providerAuthEnvVars) {
|
||||
if (
|
||||
!providerAuthEnvVars ||
|
||||
@@ -40,25 +31,10 @@ function normalizeProviderAuthEnvVars(providerAuthEnvVars) {
|
||||
|
||||
export function collectBundledProviderAuthEnvVars(params = {}) {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const extensionsRoot = path.join(repoRoot, "extensions");
|
||||
if (!fs.existsSync(extensionsRoot)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const entries = new Map();
|
||||
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
for (const source of collectBundledPluginSources({ repoRoot })) {
|
||||
for (const [providerId, envVars] of normalizeProviderAuthEnvVars(
|
||||
manifest.providerAuthEnvVars,
|
||||
source.manifest.providerAuthEnvVars,
|
||||
)) {
|
||||
entries.set(providerId, envVars);
|
||||
}
|
||||
@@ -89,43 +65,19 @@ ${renderedEntries}
|
||||
|
||||
export function writeBundledProviderAuthEnvVarModule(params = {}) {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH);
|
||||
const next = renderBundledProviderAuthEnvVarModule(
|
||||
collectBundledProviderAuthEnvVars({ repoRoot }),
|
||||
);
|
||||
const current = readIfExists(outputPath);
|
||||
const changed = current !== next;
|
||||
|
||||
if (params.check) {
|
||||
return {
|
||||
changed,
|
||||
wrote: false,
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changed,
|
||||
wrote: writeTextFileIfChanged(outputPath, next),
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
const result = writeBundledProviderAuthEnvVarModule({
|
||||
check: process.argv.includes("--check"),
|
||||
return writeGeneratedOutput({
|
||||
repoRoot,
|
||||
outputPath: params.outputPath ?? DEFAULT_OUTPUT_PATH,
|
||||
next,
|
||||
check: params.check,
|
||||
});
|
||||
|
||||
if (result.changed) {
|
||||
if (process.argv.includes("--check")) {
|
||||
console.error(
|
||||
`[bundled-provider-auth-env-vars] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`[bundled-provider-auth-env-vars] wrote ${path.relative(process.cwd(), result.outputPath)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportGeneratedOutputCli({
|
||||
importMetaUrl: import.meta.url,
|
||||
label: "bundled-provider-auth-env-vars",
|
||||
run: ({ check }) => writeBundledProviderAuthEnvVarModule({ check }),
|
||||
});
|
||||
|
||||
169
scripts/lib/arg-utils.mjs
Normal file
169
scripts/lib/arg-utils.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
export function readEnvNumber(name, env = process.env) {
|
||||
const raw = env[name]?.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function consumeStringFlag(argv, index, flag, currentValue) {
|
||||
if (argv[index] !== flag) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextIndex: index + 1,
|
||||
value: argv[index + 1] ?? currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeStringListFlag(argv, index, flag) {
|
||||
if (argv[index] !== flag) {
|
||||
return null;
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
return {
|
||||
nextIndex: index + 1,
|
||||
value: typeof value === "string" && value.length > 0 ? value : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeIntFlag(argv, index, flag, currentValue, options = {}) {
|
||||
if (argv[index] !== flag) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(argv[index + 1] ?? "", 10);
|
||||
const min = options.min ?? Number.NEGATIVE_INFINITY;
|
||||
return {
|
||||
nextIndex: index + 1,
|
||||
value: Number.isFinite(parsed) && parsed >= min ? parsed : currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function consumeFloatFlag(argv, index, flag, currentValue, options = {}) {
|
||||
if (argv[index] !== flag) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(argv[index + 1] ?? "");
|
||||
const min = options.min ?? Number.NEGATIVE_INFINITY;
|
||||
const includeMin = options.includeMin ?? true;
|
||||
const isValid = Number.isFinite(parsed) && (includeMin ? parsed >= min : parsed > min);
|
||||
return {
|
||||
nextIndex: index + 1,
|
||||
value: isValid ? parsed : currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function stringFlag(flag, key) {
|
||||
return {
|
||||
consume(argv, index, args) {
|
||||
const option = consumeStringFlag(argv, index, flag, args[key]);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextIndex: option.nextIndex,
|
||||
apply(target) {
|
||||
target[key] = option.value;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function stringListFlag(flag, key) {
|
||||
return {
|
||||
consume(argv, index) {
|
||||
const option = consumeStringListFlag(argv, index, flag);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextIndex: option.nextIndex,
|
||||
apply(target) {
|
||||
if (option.value) {
|
||||
target[key].push(option.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAssignedValueFlag(consumeOption) {
|
||||
return {
|
||||
consume(argv, index, args) {
|
||||
const option = consumeOption(argv, index, args);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextIndex: option.nextIndex,
|
||||
apply(target) {
|
||||
target[option.key] = option.value;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function intFlag(flag, key, options) {
|
||||
return createAssignedValueFlag((argv, index, args) => {
|
||||
const option = consumeIntFlag(argv, index, flag, args[key], options);
|
||||
return option ? { ...option, key } : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function floatFlag(flag, key, options) {
|
||||
return createAssignedValueFlag((argv, index, args) => {
|
||||
const option = consumeFloatFlag(argv, index, flag, args[key], options);
|
||||
return option ? { ...option, key } : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function booleanFlag(flag, key, value = true) {
|
||||
return {
|
||||
consume(argv, index) {
|
||||
if (argv[index] !== flag) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextIndex: index,
|
||||
apply(target) {
|
||||
target[key] = value;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFlagArgs(argv, args, specs, options = {}) {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--" && options.ignoreDoubleDash) {
|
||||
continue;
|
||||
}
|
||||
let handled = false;
|
||||
for (const spec of specs) {
|
||||
const option = spec.consume(argv, i, args);
|
||||
if (!option) {
|
||||
continue;
|
||||
}
|
||||
option.apply(args);
|
||||
i = option.nextIndex;
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) {
|
||||
continue;
|
||||
}
|
||||
const fallbackResult = options.onUnhandledArg?.(arg, args);
|
||||
if (fallbackResult === "handled") {
|
||||
continue;
|
||||
}
|
||||
if (!options.allowUnknownOptions && arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
51
scripts/lib/bundled-plugin-source-utils.mjs
Normal file
51
scripts/lib/bundled-plugin-source-utils.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function readIfExists(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function collectBundledPluginSources(params = {}) {
|
||||
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
|
||||
const extensionsRoot = path.join(repoRoot, "extensions");
|
||||
if (!fs.existsSync(extensionsRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requirePackageJson = params.requirePackageJson === true;
|
||||
const entries = [];
|
||||
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginDir = path.join(extensionsRoot, dirent.name);
|
||||
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
|
||||
const packageJsonPath = path.join(pluginDir, "package.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
if (requirePackageJson && !fs.existsSync(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
dirName: dirent.name,
|
||||
pluginDir,
|
||||
manifestPath,
|
||||
manifest: JSON.parse(fs.readFileSync(manifestPath, "utf8")),
|
||||
...(fs.existsSync(packageJsonPath)
|
||||
? {
|
||||
packageJsonPath,
|
||||
packageJson: JSON.parse(fs.readFileSync(packageJsonPath, "utf8")),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return entries.toSorted((left, right) => left.dirName.localeCompare(right.dirName));
|
||||
}
|
||||
45
scripts/lib/generated-output-utils.mjs
Normal file
45
scripts/lib/generated-output-utils.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { writeTextFileIfChanged } from "../runtime-postbuild-shared.mjs";
|
||||
import { readIfExists } from "./bundled-plugin-source-utils.mjs";
|
||||
|
||||
export function writeGeneratedOutput(params) {
|
||||
const outputPath = path.resolve(params.repoRoot, params.outputPath);
|
||||
const current = readIfExists(outputPath);
|
||||
const changed = current !== params.next;
|
||||
|
||||
if (params.check) {
|
||||
return {
|
||||
changed,
|
||||
wrote: false,
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changed,
|
||||
wrote: writeTextFileIfChanged(outputPath, params.next),
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function reportGeneratedOutputCli(params) {
|
||||
if (params.importMetaUrl !== pathToFileURL(process.argv[1] ?? "").href) {
|
||||
return;
|
||||
}
|
||||
|
||||
const check = process.argv.includes("--check");
|
||||
const result = params.run({ check });
|
||||
if (!result.changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativeOutputPath = path.relative(process.cwd(), result.outputPath);
|
||||
if (check) {
|
||||
console.error(`[${params.label}] stale generated output at ${relativeOutputPath}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${params.label}] wrote ${relativeOutputPath}`);
|
||||
}
|
||||
128
scripts/lib/guard-inventory-utils.mjs
Normal file
128
scripts/lib/guard-inventory-utils.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function normalizeRepoPath(repoRoot, filePath) {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
export function resolveRepoSpecifier(repoRoot, specifier, importerFile) {
|
||||
if (specifier.startsWith(".")) {
|
||||
return normalizeRepoPath(repoRoot, path.resolve(path.dirname(importerFile), specifier));
|
||||
}
|
||||
if (specifier.startsWith("/")) {
|
||||
return normalizeRepoPath(repoRoot, specifier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function visitModuleSpecifiers(ts, sourceFile, visit) {
|
||||
function walk(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
visit({
|
||||
kind: "import",
|
||||
node,
|
||||
specifier: node.moduleSpecifier.text,
|
||||
specifierNode: node.moduleSpecifier,
|
||||
});
|
||||
} else if (
|
||||
ts.isExportDeclaration(node) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
visit({
|
||||
kind: "export",
|
||||
node,
|
||||
specifier: node.moduleSpecifier.text,
|
||||
specifierNode: node.moduleSpecifier,
|
||||
});
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0])
|
||||
) {
|
||||
visit({
|
||||
kind: "dynamic-import",
|
||||
node,
|
||||
specifier: node.arguments[0].text,
|
||||
specifierNode: node.arguments[0],
|
||||
});
|
||||
}
|
||||
|
||||
ts.forEachChild(node, walk);
|
||||
}
|
||||
|
||||
walk(sourceFile);
|
||||
}
|
||||
|
||||
export function diffInventoryEntries(expected, actual, compareEntries) {
|
||||
const expectedKeys = new Set(expected.map((entry) => JSON.stringify(entry)));
|
||||
const actualKeys = new Set(actual.map((entry) => JSON.stringify(entry)));
|
||||
return {
|
||||
missing: expected
|
||||
.filter((entry) => !actualKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
unexpected: actual
|
||||
.filter((entry) => !expectedKeys.has(JSON.stringify(entry)))
|
||||
.toSorted(compareEntries),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeLine(stream, text) {
|
||||
stream.write(`${text}\n`);
|
||||
}
|
||||
|
||||
export async function collectTypeScriptInventory(params) {
|
||||
const inventory = [];
|
||||
|
||||
for (const filePath of params.files) {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const sourceFile = params.ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
params.ts.ScriptTarget.Latest,
|
||||
true,
|
||||
params.scriptKind ?? params.ts.ScriptKind.TS,
|
||||
);
|
||||
inventory.push(...params.collectEntries(sourceFile, filePath));
|
||||
}
|
||||
|
||||
return inventory.toSorted(params.compareEntries);
|
||||
}
|
||||
|
||||
export async function runBaselineInventoryCheck(params) {
|
||||
const streams = params.io ?? { stdout: process.stdout, stderr: process.stderr };
|
||||
const json = params.argv.includes("--json");
|
||||
const actual = await params.collectActual();
|
||||
const expected = await params.readExpected();
|
||||
const { missing, unexpected } = params.diffInventory(expected, actual);
|
||||
const matchesBaseline = missing.length === 0 && unexpected.length === 0;
|
||||
|
||||
if (json) {
|
||||
writeLine(streams.stdout, JSON.stringify(actual, null, 2));
|
||||
} else {
|
||||
writeLine(streams.stdout, params.formatInventoryHuman(actual));
|
||||
writeLine(
|
||||
streams.stdout,
|
||||
matchesBaseline
|
||||
? `Baseline matches (${actual.length} entries).`
|
||||
: `Baseline mismatch (${unexpected.length} unexpected, ${missing.length} missing).`,
|
||||
);
|
||||
if (!matchesBaseline) {
|
||||
if (unexpected.length > 0) {
|
||||
writeLine(streams.stderr, "Unexpected entries:");
|
||||
for (const entry of unexpected) {
|
||||
writeLine(streams.stderr, `- ${params.formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
writeLine(streams.stderr, "Missing baseline entries:");
|
||||
for (const entry of missing) {
|
||||
writeLine(streams.stderr, `- ${params.formatEntry(entry)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchesBaseline ? 0 : 1;
|
||||
}
|
||||
@@ -148,6 +148,21 @@ export function unwrapExpression(expression) {
|
||||
}
|
||||
}
|
||||
|
||||
export function collectCallExpressionLines(ts, sourceFile, resolveLineNode) {
|
||||
const lines = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const lineNode = resolveLineNode(node);
|
||||
if (lineNode) {
|
||||
lines.push(toLine(sourceFile, lineNode));
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function isDirectExecution(importMetaUrl) {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) {
|
||||
|
||||
31
scripts/lib/vitest-report-cli-utils.mjs
Normal file
31
scripts/lib/vitest-report-cli-utils.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readJsonFile, runVitestJsonReport } from "../test-report-utils.mjs";
|
||||
import { intFlag, parseFlagArgs, stringFlag } from "./arg-utils.mjs";
|
||||
|
||||
export function parseVitestReportArgs(argv, defaults) {
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
{
|
||||
config: defaults.config,
|
||||
limit: defaults.limit,
|
||||
reportPath: defaults.reportPath ?? "",
|
||||
},
|
||||
[
|
||||
stringFlag("--config", "config"),
|
||||
intFlag("--limit", "limit", { min: 1 }),
|
||||
stringFlag("--report", "reportPath"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function loadVitestReportFromArgs(args, prefix) {
|
||||
const reportPath = runVitestJsonReport({
|
||||
config: args.config,
|
||||
reportPath: args.reportPath,
|
||||
prefix,
|
||||
});
|
||||
return readJsonFile(reportPath);
|
||||
}
|
||||
|
||||
export function formatMs(value, digits = 1) {
|
||||
return `${value.toFixed(digits)}ms`;
|
||||
}
|
||||
@@ -1,81 +1,49 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
booleanFlag,
|
||||
floatFlag,
|
||||
intFlag,
|
||||
parseFlagArgs,
|
||||
readEnvNumber,
|
||||
stringFlag,
|
||||
} from "./lib/arg-utils.mjs";
|
||||
import { formatMs } from "./lib/vitest-report-cli-utils.mjs";
|
||||
import { loadTestRunnerBehavior, loadUnitTimingManifest } from "./test-runner-manifest.mjs";
|
||||
|
||||
function readEnvNumber(name) {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const args = {
|
||||
config: "vitest.unit.config.ts",
|
||||
limit: Number.isFinite(readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_LIMIT"))
|
||||
? Math.max(1, Math.floor(readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_LIMIT")))
|
||||
: 20,
|
||||
minDurationMs: readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_MIN_DURATION_MS") ?? 250,
|
||||
minGainMs: readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_MIN_GAIN_MS") ?? 100,
|
||||
minGainPct: readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_MIN_GAIN_PCT") ?? 10,
|
||||
json: false,
|
||||
files: [],
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--") {
|
||||
continue;
|
||||
}
|
||||
if (arg === "--config") {
|
||||
args.config = argv[i + 1] ?? args.config;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--limit") {
|
||||
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.limit = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--min-duration-ms") {
|
||||
const parsed = Number.parseFloat(argv[i + 1] ?? "");
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
args.minDurationMs = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--min-gain-ms") {
|
||||
const parsed = Number.parseFloat(argv[i + 1] ?? "");
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
args.minGainMs = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--min-gain-pct") {
|
||||
const parsed = Number.parseFloat(argv[i + 1] ?? "");
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.minGainPct = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
args.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
args.files.push(arg);
|
||||
}
|
||||
return args;
|
||||
const envLimit = readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_LIMIT");
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
{
|
||||
config: "vitest.unit.config.ts",
|
||||
limit: Number.isFinite(envLimit) ? Math.max(1, Math.floor(envLimit)) : 20,
|
||||
minDurationMs: readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_MIN_DURATION_MS") ?? 250,
|
||||
minGainMs: readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_MIN_GAIN_MS") ?? 100,
|
||||
minGainPct: readEnvNumber("OPENCLAW_TEST_THREAD_CANDIDATE_MIN_GAIN_PCT") ?? 10,
|
||||
json: false,
|
||||
files: [],
|
||||
},
|
||||
[
|
||||
stringFlag("--config", "config"),
|
||||
intFlag("--limit", "limit", { min: 1 }),
|
||||
floatFlag("--min-duration-ms", "minDurationMs", { min: 0 }),
|
||||
floatFlag("--min-gain-ms", "minGainMs", { min: 0 }),
|
||||
floatFlag("--min-gain-pct", "minGainPct", { min: 0, includeMin: false }),
|
||||
booleanFlag("--json", "json"),
|
||||
],
|
||||
{
|
||||
ignoreDoubleDash: true,
|
||||
onUnhandledArg(arg, args) {
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
args.files.push(arg);
|
||||
return "handled";
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getExistingThreadCandidateExclusions(behavior) {
|
||||
@@ -131,10 +99,6 @@ export function summarizeThreadBenchmark({ file, forks, threads, minGainMs, minG
|
||||
};
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
return `${ms.toFixed(0)}ms`;
|
||||
}
|
||||
|
||||
function benchmarkFile({ config, file, pool }) {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
const run = spawnSync("pnpm", ["vitest", "run", "--config", config, `--pool=${pool}`, file], {
|
||||
@@ -203,6 +167,7 @@ async function main() {
|
||||
console.log(
|
||||
`[test-find-thread-candidates] tested=${String(results.length)} minGain=${formatMs(
|
||||
opts.minGainMs,
|
||||
0,
|
||||
)} minGainPct=${String(opts.minGainPct)}%`,
|
||||
);
|
||||
for (const result of results) {
|
||||
@@ -214,30 +179,17 @@ async function main() {
|
||||
? "threads-failed"
|
||||
: "skip";
|
||||
console.log(
|
||||
`${status.padEnd(14, " ")} ${result.file} forks=${formatMs(result.forks.elapsedMs)} threads=${formatMs(
|
||||
result.threads.elapsedMs,
|
||||
)} gain=${formatMs(result.gainMs)} (${result.gainPct.toFixed(1)}%)`,
|
||||
`${status.padEnd(14, " ")} ${result.file} forks=${formatMs(
|
||||
result.forks.elapsedMs,
|
||||
0,
|
||||
)} threads=${formatMs(result.threads.elapsedMs, 0)} gain=${formatMs(result.gainMs, 0)} (${result.gainPct.toFixed(1)}%)`,
|
||||
);
|
||||
if (result.threads.exitCode !== 0) {
|
||||
const firstErrorLine =
|
||||
result.threads.stderr
|
||||
.split(/\r?\n/u)
|
||||
.find(
|
||||
(line) => line.includes("Error") || line.includes("TypeError") || line.includes("FAIL"),
|
||||
) ?? "threads failed";
|
||||
console.log(` ${firstErrorLine}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,50 +1,15 @@
|
||||
import {
|
||||
collectVitestFileDurations,
|
||||
readJsonFile,
|
||||
runVitestJsonReport,
|
||||
} from "./test-report-utils.mjs";
|
||||
formatMs,
|
||||
loadVitestReportFromArgs,
|
||||
parseVitestReportArgs,
|
||||
} from "./lib/vitest-report-cli-utils.mjs";
|
||||
import { collectVitestFileDurations } from "./test-report-utils.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
config: "vitest.unit.config.ts",
|
||||
limit: 20,
|
||||
reportPath: "",
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--config") {
|
||||
args.config = argv[i + 1] ?? args.config;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--limit") {
|
||||
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.limit = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--report") {
|
||||
args.reportPath = argv[i + 1] ?? "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
return `${value.toFixed(1)}ms`;
|
||||
}
|
||||
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const reportPath = runVitestJsonReport({
|
||||
config: opts.config,
|
||||
reportPath: opts.reportPath,
|
||||
prefix: "openclaw-vitest-hotspots",
|
||||
const opts = parseVitestReportArgs(process.argv.slice(2), {
|
||||
config: "vitest.unit.config.ts",
|
||||
limit: 20,
|
||||
});
|
||||
const report = readJsonFile(reportPath);
|
||||
const report = loadVitestReportFromArgs(opts, "openclaw-vitest-hotspots");
|
||||
const fileResults = collectVitestFileDurations(report).toSorted(
|
||||
(a, b) => b.durationMs - a.durationMs,
|
||||
);
|
||||
|
||||
@@ -1,58 +1,23 @@
|
||||
import { floatFlag, parseFlagArgs, readEnvNumber, stringFlag } from "./lib/arg-utils.mjs";
|
||||
import { formatMs } from "./lib/vitest-report-cli-utils.mjs";
|
||||
import { readJsonFile, runVitestJsonReport } from "./test-report-utils.mjs";
|
||||
|
||||
function readEnvNumber(name) {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
config: "vitest.unit.config.ts",
|
||||
maxWallMs: readEnvNumber("OPENCLAW_TEST_PERF_MAX_WALL_MS"),
|
||||
baselineWallMs: readEnvNumber("OPENCLAW_TEST_PERF_BASELINE_WALL_MS"),
|
||||
maxRegressionPct: readEnvNumber("OPENCLAW_TEST_PERF_MAX_REGRESSION_PCT") ?? 10,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--config") {
|
||||
args.config = argv[i + 1] ?? args.config;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max-wall-ms") {
|
||||
const parsed = Number.parseFloat(argv[i + 1] ?? "");
|
||||
if (Number.isFinite(parsed)) {
|
||||
args.maxWallMs = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--baseline-wall-ms") {
|
||||
const parsed = Number.parseFloat(argv[i + 1] ?? "");
|
||||
if (Number.isFinite(parsed)) {
|
||||
args.baselineWallMs = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max-regression-pct") {
|
||||
const parsed = Number.parseFloat(argv[i + 1] ?? "");
|
||||
if (Number.isFinite(parsed)) {
|
||||
args.maxRegressionPct = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
return `${ms.toFixed(1)}ms`;
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
{
|
||||
config: "vitest.unit.config.ts",
|
||||
maxWallMs: readEnvNumber("OPENCLAW_TEST_PERF_MAX_WALL_MS"),
|
||||
baselineWallMs: readEnvNumber("OPENCLAW_TEST_PERF_BASELINE_WALL_MS"),
|
||||
maxRegressionPct: readEnvNumber("OPENCLAW_TEST_PERF_MAX_REGRESSION_PCT") ?? 10,
|
||||
},
|
||||
[
|
||||
stringFlag("--config", "config"),
|
||||
floatFlag("--max-wall-ms", "maxWallMs"),
|
||||
floatFlag("--baseline-wall-ms", "baselineWallMs"),
|
||||
floatFlag("--max-regression-pct", "maxRegressionPct"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -261,24 +261,14 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const buckets = Array.from({ length: Math.min(normalizedBucketCount, files.length) }, () => ({
|
||||
totalMs: 0,
|
||||
files: [],
|
||||
}));
|
||||
|
||||
const sortedFiles = [...files].toSorted((left, right) => {
|
||||
return estimateDurationMs(right) - estimateDurationMs(left);
|
||||
});
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
const bucket = buckets.reduce((lightest, current) =>
|
||||
current.totalMs < lightest.totalMs ? current : lightest,
|
||||
);
|
||||
bucket.files.push(file);
|
||||
bucket.totalMs += estimateDurationMs(file);
|
||||
}
|
||||
|
||||
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
|
||||
return packFilesIntoDurationBuckets(
|
||||
files,
|
||||
Array.from({ length: Math.min(normalizedBucketCount, files.length) }, () => ({
|
||||
totalMs: 0,
|
||||
files: [],
|
||||
})),
|
||||
estimateDurationMs,
|
||||
).filter((bucket) => bucket.length > 0);
|
||||
}
|
||||
|
||||
export function packFilesByDurationWithBaseLoads(
|
||||
@@ -292,14 +282,20 @@ export function packFilesByDurationWithBaseLoads(
|
||||
return [];
|
||||
}
|
||||
|
||||
const buckets = Array.from({ length: normalizedBucketCount }, (_, index) => ({
|
||||
totalMs:
|
||||
Number.isFinite(baseLoadsMs[index]) && baseLoadsMs[index] >= 0
|
||||
? Math.round(baseLoadsMs[index])
|
||||
: 0,
|
||||
files: [],
|
||||
}));
|
||||
return packFilesIntoDurationBuckets(
|
||||
files,
|
||||
Array.from({ length: normalizedBucketCount }, (_, index) => ({
|
||||
totalMs:
|
||||
Number.isFinite(baseLoadsMs[index]) && baseLoadsMs[index] >= 0
|
||||
? Math.round(baseLoadsMs[index])
|
||||
: 0,
|
||||
files: [],
|
||||
})),
|
||||
estimateDurationMs,
|
||||
);
|
||||
}
|
||||
|
||||
function packFilesIntoDurationBuckets(files, buckets, estimateDurationMs) {
|
||||
const sortedFiles = [...files].toSorted((left, right) => {
|
||||
return estimateDurationMs(right) - estimateDurationMs(left);
|
||||
});
|
||||
|
||||
@@ -1,71 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { intFlag, parseFlagArgs, stringFlag, stringListFlag } from "./lib/arg-utils.mjs";
|
||||
import { parseMemoryTraceSummaryLines } from "./test-parallel-memory.mjs";
|
||||
import { normalizeTrackedRepoPath, tryReadJsonFile, writeJsonFile } from "./test-report-utils.mjs";
|
||||
import { unitMemoryHotspotManifestPath } from "./test-runner-manifest.mjs";
|
||||
import { matchesHotspotSummaryLane } from "./test-update-memory-hotspots-utils.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
config: "vitest.unit.config.ts",
|
||||
out: unitMemoryHotspotManifestPath,
|
||||
lane: "unit-fast",
|
||||
lanePrefixes: [],
|
||||
logs: [],
|
||||
minDeltaKb: 256 * 1024,
|
||||
limit: 64,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--config") {
|
||||
args.config = argv[i + 1] ?? args.config;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out") {
|
||||
args.out = argv[i + 1] ?? args.out;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--lane") {
|
||||
args.lane = argv[i + 1] ?? args.lane;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--lane-prefix") {
|
||||
const lanePrefix = argv[i + 1];
|
||||
if (typeof lanePrefix === "string" && lanePrefix.length > 0) {
|
||||
args.lanePrefixes.push(lanePrefix);
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--log") {
|
||||
const logPath = argv[i + 1];
|
||||
if (typeof logPath === "string" && logPath.length > 0) {
|
||||
args.logs.push(logPath);
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--min-delta-kb") {
|
||||
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.minDeltaKb = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--limit") {
|
||||
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.limit = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
{
|
||||
config: "vitest.unit.config.ts",
|
||||
out: unitMemoryHotspotManifestPath,
|
||||
lane: "unit-fast",
|
||||
lanePrefixes: [],
|
||||
logs: [],
|
||||
minDeltaKb: 256 * 1024,
|
||||
limit: 64,
|
||||
},
|
||||
[
|
||||
stringFlag("--config", "config"),
|
||||
stringFlag("--out", "out"),
|
||||
stringFlag("--lane", "lane"),
|
||||
stringListFlag("--lane-prefix", "lanePrefixes"),
|
||||
stringListFlag("--log", "logs"),
|
||||
intFlag("--min-delta-kb", "minDeltaKb", { min: 1 }),
|
||||
intFlag("--limit", "limit", { min: 1 }),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function mergeHotspotEntry(aggregated, file, value) {
|
||||
|
||||
@@ -1,64 +1,30 @@
|
||||
import { intFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
|
||||
import { loadVitestReportFromArgs, parseVitestReportArgs } from "./lib/vitest-report-cli-utils.mjs";
|
||||
import {
|
||||
collectVitestFileDurations,
|
||||
normalizeTrackedRepoPath,
|
||||
readJsonFile,
|
||||
runVitestJsonReport,
|
||||
writeJsonFile,
|
||||
} from "./test-report-utils.mjs";
|
||||
import { unitTimingManifestPath } from "./test-runner-manifest.mjs";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
config: "vitest.unit.config.ts",
|
||||
out: unitTimingManifestPath,
|
||||
reportPath: "",
|
||||
limit: 256,
|
||||
defaultDurationMs: 250,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--config") {
|
||||
args.config = argv[i + 1] ?? args.config;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out") {
|
||||
args.out = argv[i + 1] ?? args.out;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--report") {
|
||||
args.reportPath = argv[i + 1] ?? "";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--limit") {
|
||||
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.limit = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--default-duration-ms") {
|
||||
const parsed = Number.parseInt(argv[i + 1] ?? "", 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
args.defaultDurationMs = parsed;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
return parseFlagArgs(
|
||||
argv,
|
||||
{
|
||||
...parseVitestReportArgs(argv, {
|
||||
config: "vitest.unit.config.ts",
|
||||
limit: 256,
|
||||
reportPath: "",
|
||||
}),
|
||||
out: unitTimingManifestPath,
|
||||
defaultDurationMs: 250,
|
||||
},
|
||||
[stringFlag("--out", "out"), intFlag("--default-duration-ms", "defaultDurationMs", { min: 1 })],
|
||||
);
|
||||
}
|
||||
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const reportPath = runVitestJsonReport({
|
||||
config: opts.config,
|
||||
reportPath: opts.reportPath,
|
||||
prefix: "openclaw-vitest-timings",
|
||||
});
|
||||
const report = readJsonFile(reportPath);
|
||||
const report = loadVitestReportFromArgs(opts, "openclaw-vitest-timings");
|
||||
const files = Object.fromEntries(
|
||||
collectVitestFileDurations(report, normalizeTrackedRepoPath)
|
||||
.toSorted((a, b) => b.durationMs - a.durationMs)
|
||||
|
||||
Reference in New Issue
Block a user