mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
c23d66e3b5
* refactor: consolidate coercion ownership * test: align shard check with weighted planning * chore: refresh plugin SDK API baseline
541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import ts from "typescript";
|
|
import { isChangedLaneTestPath } from "./changed-lanes.mts";
|
|
import {
|
|
booleanFlag,
|
|
isOpenEndedTruthyValue,
|
|
parseFlagArgs,
|
|
stringFlag,
|
|
} from "./lib/arg-utils.mts";
|
|
import { runAsScript } from "./lib/ts-guard-utils.mts";
|
|
|
|
type AddedLine = {
|
|
line: number;
|
|
source: string;
|
|
};
|
|
|
|
type TempCreationFinding = {
|
|
file: string;
|
|
line: number;
|
|
reason: string;
|
|
source: string;
|
|
};
|
|
|
|
type ManualHelperImport = {
|
|
line: number;
|
|
source: string;
|
|
};
|
|
|
|
type AllowNextLine = {
|
|
file: string;
|
|
line: number;
|
|
};
|
|
|
|
type ScriptIo = {
|
|
env?: NodeJS.ProcessEnv;
|
|
stderr?: { write: (text: string) => unknown };
|
|
stdout?: { write: (text: string) => unknown };
|
|
};
|
|
|
|
const DEFAULT_BASE_REF = "origin/main";
|
|
const DEFAULT_HEAD_REF = "HEAD";
|
|
const TEMP_DIR_HELPER_PATH = "test/helpers/temp-dir.ts";
|
|
const TEMP_DIR_HELPER_TEST_PATH = "test/helpers/temp-dir.test.ts";
|
|
const MANUAL_TEMP_DIR_HELPERS = new Set(["cleanupTempDirs", "createTempDirTracker", "makeTempDir"]);
|
|
const FINDING_PATTERNS = [
|
|
{
|
|
pattern: /\bmkdtemp(?:Sync)?\s*\(/u,
|
|
reason: "new mkdtemp temp directory creation",
|
|
},
|
|
{
|
|
pattern: /\btmp\s*\.\s*dir(?:Sync)?\s*\(/u,
|
|
reason: "new tmp.dir temp directory creation",
|
|
},
|
|
];
|
|
const TEMP_DIR_ALLOW_COMMENT_RE =
|
|
/(?:^|\s)(?:\/\/|\/\*|\*|#)\s*openclaw-temp-dir:\s*allow\s+(.+)$/u;
|
|
|
|
function usage(): string {
|
|
return `Usage: node scripts/report-test-temp-creations.mjs [options]
|
|
|
|
Description:
|
|
Reports new test temp-directory migration warnings in added diff lines.
|
|
This is a low-noise migration aid, not a cleanup data-flow checker. It does
|
|
not scan existing lines for bare temp dirs and does not decide whether cleanup is sufficient.
|
|
Add "openclaw-temp-dir: allow <reason>" in a same-line or immediately
|
|
preceding added comment when a test intentionally needs bare temp creation.
|
|
File scope intentionally reuses scripts/changed-lanes.mjs test-path
|
|
classification instead of maintaining a separate test-helper heuristic.
|
|
|
|
Options:
|
|
--base <ref> Base ref for branch diffs. Default: ${DEFAULT_BASE_REF}
|
|
--head <ref> Head ref for branch diffs. Default: ${DEFAULT_HEAD_REF}
|
|
--no-merge-base Use a two-dot base..head diff for shallow CI checkouts.
|
|
--staged Inspect staged changes instead of a branch diff.
|
|
--json Print JSON findings to stdout.
|
|
--fail-on-findings Exit 1 when findings are present. Default is report-only.
|
|
-h, --help Show this help.
|
|
|
|
Outputs:
|
|
Human mode prints findings to stderr and exits 0 unless --fail-on-findings is set.
|
|
GitHub Actions mode prints warning annotations and exits 0 unless --fail-on-findings is set.
|
|
JSON mode prints an array of { file, line, reason, source } to stdout.
|
|
|
|
Examples:
|
|
node scripts/report-test-temp-creations.mjs --base origin/main --head HEAD
|
|
node scripts/report-test-temp-creations.mjs --staged --json
|
|
`;
|
|
}
|
|
|
|
function normalizePath(filePath: string): string {
|
|
return filePath.replaceAll("\\", "/").replace(/^\.\/+/u, "");
|
|
}
|
|
|
|
function shouldInspectFile(filePath: string): boolean {
|
|
const normalizedPath = normalizePath(filePath);
|
|
return normalizedPath !== TEMP_DIR_HELPER_PATH && isChangedLaneTestPath(normalizedPath);
|
|
}
|
|
|
|
function shouldInspectManualHelperUsage(filePath: string): boolean {
|
|
const normalizedPath = normalizePath(filePath);
|
|
return normalizedPath !== TEMP_DIR_HELPER_TEST_PATH && shouldInspectFile(normalizedPath);
|
|
}
|
|
|
|
function escapeGithubCommandValue(value: unknown): string {
|
|
return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
}
|
|
|
|
function escapeGithubCommandProperty(value: unknown): string {
|
|
return escapeGithubCommandValue(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
}
|
|
|
|
function hasTempDirAllowMarker(source: string): boolean {
|
|
const reason = source.match(TEMP_DIR_ALLOW_COMMENT_RE)?.[1]?.trim() ?? "";
|
|
return reason.length > 0;
|
|
}
|
|
|
|
function isTempDirAllowComment(source: string): boolean {
|
|
const trimmed = source.trim();
|
|
return /^(?:\/\/|\/\*|\*|#)/u.test(trimmed) && hasTempDirAllowMarker(trimmed);
|
|
}
|
|
|
|
export function formatGithubWarning(finding: TempCreationFinding): string {
|
|
const file = escapeGithubCommandProperty(finding.file);
|
|
const line = escapeGithubCommandProperty(finding.line);
|
|
const message = escapeGithubCommandValue(
|
|
`${finding.reason}: prefer useAutoCleanupTempDirTracker(afterEach) from test/helpers/temp-dir.ts for new test-owned temp directories.`,
|
|
);
|
|
return `::warning file=${file},line=${line}::${message}`;
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]) {
|
|
const args = {
|
|
base: DEFAULT_BASE_REF,
|
|
failOnFindings: false,
|
|
head: DEFAULT_HEAD_REF,
|
|
help: false,
|
|
json: false,
|
|
noMergeBase: false,
|
|
staged: false,
|
|
};
|
|
return parseFlagArgs(argv, args, [
|
|
stringFlag("--base", "base"),
|
|
booleanFlag("--fail-on-findings", "failOnFindings"),
|
|
stringFlag("--head", "head"),
|
|
booleanFlag("-h", "help"),
|
|
booleanFlag("--help", "help"),
|
|
booleanFlag("--json", "json"),
|
|
booleanFlag("--no-merge-base", "noMergeBase"),
|
|
booleanFlag("--staged", "staged"),
|
|
]);
|
|
}
|
|
|
|
function readDiff(args: ReturnType<typeof parseArgs>, cwd = process.cwd()): string {
|
|
let useMergeBase = !args.noMergeBase;
|
|
if (useMergeBase && !args.staged) {
|
|
const mergeBase = spawnSync("git", ["merge-base", args.base, args.head], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
});
|
|
if (mergeBase.error) {
|
|
throw mergeBase.error;
|
|
}
|
|
if (mergeBase.status === 1) {
|
|
// Delegated CI syncs can contain both refs without their shared history.
|
|
// Compare the endpoint trees so this warning-only report still runs.
|
|
useMergeBase = false;
|
|
} else if (mergeBase.status !== 0) {
|
|
throw new Error(mergeBase.stderr.trim() || "git merge-base failed");
|
|
}
|
|
}
|
|
const range = useMergeBase ? `${args.base}...${args.head}` : `${args.base}..${args.head}`;
|
|
const diffArgs = args.staged
|
|
? ["diff", "--cached", "--unified=0", "--diff-filter=ACMR", "--"]
|
|
: ["diff", "--unified=0", "--diff-filter=ACMR", range, "--"];
|
|
return execFileSync("git", diffArgs, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
}
|
|
|
|
function readWorktreeSource(filePath: string, cwd: string): string {
|
|
try {
|
|
return fs.readFileSync(path.join(cwd, filePath), "utf8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readStagedSource(filePath: string, cwd: string): string {
|
|
try {
|
|
return execFileSync("git", ["show", `:${filePath}`], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readSourceForDiff(
|
|
filePath: string,
|
|
args: ReturnType<typeof parseArgs>,
|
|
cwd: string,
|
|
): string {
|
|
// Staged checks must parse the index blob. Reading the worktree mixes in
|
|
// unstaged edits and can warn on code that will not be committed.
|
|
return args.staged ? readStagedSource(filePath, cwd) : readWorktreeSource(filePath, cwd);
|
|
}
|
|
|
|
function stripKnownExtension(filePath: string): string {
|
|
return filePath.replace(/\.(?:c|m)?[jt]sx?$/u, "");
|
|
}
|
|
|
|
function isTempDirHelperImportSpec(filePath: string, specifier: string): boolean {
|
|
const normalizedSpecifier = normalizePath(specifier);
|
|
const resolvedPath = normalizedSpecifier.startsWith(".")
|
|
? path.posix.normalize(path.posix.join(path.posix.dirname(filePath), normalizedSpecifier))
|
|
: normalizedSpecifier;
|
|
return stripKnownExtension(resolvedPath) === stripKnownExtension(TEMP_DIR_HELPER_PATH);
|
|
}
|
|
|
|
function createSourceFile(filePath: string, sourceText: string): ts.SourceFile {
|
|
return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
|
}
|
|
|
|
function lineForNode(sourceFile: ts.SourceFile, node: ts.Node): number {
|
|
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
}
|
|
|
|
function sourceLineText(sourceFile: ts.SourceFile, line: number): string {
|
|
const lineStarts = sourceFile.getLineStarts();
|
|
const start = lineStarts[line - 1] ?? 0;
|
|
const end = lineStarts[line] ?? sourceFile.text.length;
|
|
return sourceFile.text.slice(start, end).trim();
|
|
}
|
|
|
|
function nodeOverlapsAddedLine(
|
|
sourceFile: ts.SourceFile,
|
|
node: ts.Node,
|
|
addedLineNumbers: Set<number>,
|
|
): boolean {
|
|
const startLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
for (let line = startLine; line <= endLine; line += 1) {
|
|
if (addedLineNumbers.has(line)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeFileTextMap(
|
|
fileTextByPath: Map<string, string> | Record<string, string> | undefined,
|
|
) {
|
|
if (!fileTextByPath) {
|
|
return null;
|
|
}
|
|
if (fileTextByPath instanceof Map) {
|
|
return fileTextByPath;
|
|
}
|
|
return new Map(Object.entries(fileTextByPath));
|
|
}
|
|
|
|
function readCurrentSource(
|
|
filePath: string,
|
|
options: { readFile?: (filePath: string) => string | null | undefined },
|
|
fileTextByPath: Map<string, string> | null,
|
|
): string {
|
|
if (fileTextByPath?.has(filePath)) {
|
|
return fileTextByPath.get(filePath) ?? "";
|
|
}
|
|
if (options.readFile) {
|
|
return options.readFile(filePath) ?? "";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function collectManualTempDirHelperImports(
|
|
sourceFile: ts.SourceFile,
|
|
filePath: string,
|
|
addedLineNumbers: Set<number> | null = null,
|
|
): { imports: ManualHelperImport[]; localNames: Set<string> } {
|
|
const imports: ManualHelperImport[] = [];
|
|
const localNames = new Set<string>();
|
|
for (const statement of sourceFile.statements) {
|
|
if (
|
|
!ts.isImportDeclaration(statement) ||
|
|
!statement.importClause?.namedBindings ||
|
|
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
!isTempDirHelperImportSpec(filePath, statement.moduleSpecifier.text) ||
|
|
!ts.isNamedImports(statement.importClause.namedBindings)
|
|
) {
|
|
continue;
|
|
}
|
|
let importWarningLine: number | null = null;
|
|
for (const element of statement.importClause.namedBindings.elements) {
|
|
const imported = element.propertyName?.text ?? element.name.text;
|
|
if (!MANUAL_TEMP_DIR_HELPERS.has(imported)) {
|
|
continue;
|
|
}
|
|
localNames.add(element.name.text);
|
|
if (
|
|
importWarningLine === null &&
|
|
(!addedLineNumbers || nodeOverlapsAddedLine(sourceFile, element, addedLineNumbers))
|
|
) {
|
|
importWarningLine = lineForNode(sourceFile, element);
|
|
}
|
|
}
|
|
if (importWarningLine !== null) {
|
|
imports.push({
|
|
line: importWarningLine,
|
|
source: statement.getText(sourceFile).trim().replace(/\s+/gu, " "),
|
|
});
|
|
}
|
|
}
|
|
return { imports, localNames };
|
|
}
|
|
|
|
function findManualHelperUsageFindings(
|
|
filePath: string,
|
|
sourceText: string,
|
|
addedLines: AddedLine[],
|
|
): TempCreationFinding[] {
|
|
const addedLineNumbers = new Set(addedLines.map((line) => line.line));
|
|
const sourceFile = createSourceFile(filePath, sourceText);
|
|
const { imports, localNames } = collectManualTempDirHelperImports(
|
|
sourceFile,
|
|
filePath,
|
|
addedLineNumbers,
|
|
);
|
|
const findings = imports.map((manualImport) => ({
|
|
file: filePath,
|
|
line: manualImport.line,
|
|
reason: "new manual temp-dir helper import",
|
|
source: manualImport.source,
|
|
}));
|
|
if (localNames.size === 0) {
|
|
return findings;
|
|
}
|
|
const visit = (node: ts.Node): void => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
localNames.has(node.expression.text) &&
|
|
nodeOverlapsAddedLine(sourceFile, node.expression, addedLineNumbers)
|
|
) {
|
|
findings.push({
|
|
file: filePath,
|
|
line: lineForNode(sourceFile, node.expression),
|
|
reason: "new manual temp-dir helper usage",
|
|
source: sourceLineText(sourceFile, lineForNode(sourceFile, node.expression)),
|
|
});
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
return findings;
|
|
}
|
|
|
|
function collectAddedLinesByFile(diffText: string): Map<string, AddedLine[]> {
|
|
const addedLinesByFile = new Map<string, AddedLine[]>();
|
|
let currentFile: string | null = null;
|
|
let currentLine = 0;
|
|
|
|
for (const line of diffText.split(/\r?\n/u)) {
|
|
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/u);
|
|
if (fileMatch) {
|
|
const filePath = fileMatch[1];
|
|
if (!filePath) {
|
|
continue;
|
|
}
|
|
currentFile = normalizePath(filePath);
|
|
continue;
|
|
}
|
|
if (line === "+++ /dev/null") {
|
|
currentFile = null;
|
|
continue;
|
|
}
|
|
|
|
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
|
|
if (hunkMatch) {
|
|
currentLine = Number.parseInt(hunkMatch[1] ?? "0", 10);
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
if (currentFile && shouldInspectFile(currentFile)) {
|
|
const lines = addedLinesByFile.get(currentFile) ?? [];
|
|
lines.push({ line: currentLine, source: line.slice(1) });
|
|
addedLinesByFile.set(currentFile, lines);
|
|
}
|
|
currentLine += 1;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith(" ") || line === "") {
|
|
currentLine += 1;
|
|
}
|
|
}
|
|
|
|
return addedLinesByFile;
|
|
}
|
|
|
|
export function collectTempCreationFindingsFromDiff(
|
|
diffText: string,
|
|
options: {
|
|
fileTextByPath?: Map<string, string> | Record<string, string>;
|
|
readFile?: (filePath: string) => string | null | undefined;
|
|
} = {},
|
|
): TempCreationFinding[] {
|
|
const diff = diffText;
|
|
const findings: TempCreationFinding[] = [];
|
|
const addedLinesByFile = collectAddedLinesByFile(diff);
|
|
const fileTextByPath = normalizeFileTextMap(options.fileTextByPath);
|
|
let currentFile: string | null = null;
|
|
let currentLine = 0;
|
|
let allowNextLine: AllowNextLine | null = null;
|
|
|
|
for (const line of diff.split(/\r?\n/u)) {
|
|
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/u);
|
|
if (fileMatch) {
|
|
const filePath = fileMatch[1];
|
|
if (!filePath) {
|
|
continue;
|
|
}
|
|
currentFile = normalizePath(filePath);
|
|
allowNextLine = null;
|
|
continue;
|
|
}
|
|
if (line === "+++ /dev/null") {
|
|
currentFile = null;
|
|
allowNextLine = null;
|
|
continue;
|
|
}
|
|
|
|
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
|
|
if (hunkMatch) {
|
|
currentLine = Number.parseInt(hunkMatch[1] ?? "0", 10);
|
|
allowNextLine = null;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
if (currentFile && shouldInspectFile(currentFile)) {
|
|
const source = line.slice(1);
|
|
const allowed =
|
|
hasTempDirAllowMarker(source) ||
|
|
(allowNextLine?.file === currentFile && allowNextLine.line === currentLine);
|
|
for (const { pattern, reason } of FINDING_PATTERNS) {
|
|
if (pattern.test(source)) {
|
|
if (!allowed) {
|
|
findings.push({
|
|
file: currentFile,
|
|
line: currentLine,
|
|
reason,
|
|
source: source.trim(),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
allowNextLine = isTempDirAllowComment(source)
|
|
? { file: currentFile, line: currentLine + 1 }
|
|
: null;
|
|
}
|
|
currentLine += 1;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith(" ") || line === "") {
|
|
allowNextLine = null;
|
|
currentLine += 1;
|
|
}
|
|
}
|
|
|
|
for (const [file, addedLines] of addedLinesByFile) {
|
|
if (!shouldInspectManualHelperUsage(file)) {
|
|
continue;
|
|
}
|
|
const sourceText = readCurrentSource(file, options, fileTextByPath);
|
|
if (!sourceText) {
|
|
continue;
|
|
}
|
|
findings.push(...findManualHelperUsageFindings(file, sourceText, addedLines));
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
async function main(argv?: string[], io?: ScriptIo): Promise<0 | 1> {
|
|
const args = parseArgs(argv ?? process.argv.slice(2));
|
|
const stdout = io?.stdout ?? process.stdout;
|
|
const stderr = io?.stderr ?? process.stderr;
|
|
const env = io?.env ?? process.env;
|
|
if (args.help) {
|
|
stdout.write(usage());
|
|
return 0;
|
|
}
|
|
|
|
const cwd = process.cwd();
|
|
const findings = collectTempCreationFindingsFromDiff(readDiff(args, cwd), {
|
|
readFile(filePath: string) {
|
|
return readSourceForDiff(filePath, args, cwd);
|
|
},
|
|
});
|
|
if (args.json) {
|
|
stdout.write(`${JSON.stringify(findings, null, 2)}\n`);
|
|
} else if (findings.length === 0) {
|
|
stderr.write("No new test temp-directory migration warnings found.\n");
|
|
} else if (isOpenEndedTruthyValue(env.GITHUB_ACTIONS)) {
|
|
for (const finding of findings) {
|
|
stderr.write(`${formatGithubWarning(finding)}\n`);
|
|
}
|
|
} else {
|
|
stderr.write("New test temp-directory migration warnings:\n");
|
|
for (const finding of findings) {
|
|
stderr.write(`- ${finding.file}:${finding.line} ${finding.reason}: ${finding.source}\n`);
|
|
}
|
|
stderr.write(
|
|
"Prefer useAutoCleanupTempDirTracker(afterEach) from test/helpers/temp-dir.ts for new test-owned temp directories.\n",
|
|
);
|
|
}
|
|
|
|
return args.failOnFindings && findings.length > 0 ? 1 : 0;
|
|
}
|
|
|
|
runAsScript(import.meta.url, async () => {
|
|
const exitCode = await main();
|
|
process.exitCode = exitCode;
|
|
return exitCode;
|
|
});
|