refactor: dedupe test and script helpers

This commit is contained in:
Peter Steinberger
2026-03-24 15:47:44 +00:00
parent 66e954858b
commit 781295c14b
56 changed files with 2277 additions and 3522 deletions

View File

@@ -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");

View File

@@ -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");

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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 }),
});

View File

@@ -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
View 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;
}

View 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));
}

View 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}`);
}

View 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;
}

View File

@@ -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) {

View 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`;
}

View File

@@ -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);
}
});
}

View File

@@ -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,
);

View File

@@ -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));

View File

@@ -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);
});

View File

@@ -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) {

View File

@@ -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)