openclaw/scripts/report-test-temp-creations.mts
Peter Steinberger afcb342244
refactor(scripts): deslop tooling scripts third pass (#161354)
* refactor(scripts): deslop tooling scripts third pass

Consolidate AST traversal, binding and shadow handling, report grouping,
fixture metadata, and developer harness plumbing. Remove the unused PR
comment verdict parser and one-use forwarding layers while preserving CLI,
guard, baseline, and generated-byte contracts.

* test(scripts): parse mocked fetch requests with Request

* test(scripts): include shared AST helper in protocol fixture
2026-09-29 17:00:46 -07:00

494 lines
16 KiB
TypeScript

#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import * as ts from "typescript/unstable/ast";
import { isChangedLaneTestPath } from "./changed-lanes.mts";
import {
booleanFlag,
isOpenEndedTruthyValue,
parseFlagArgs,
stringFlag,
} from "./lib/arg-utils.mts";
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
import { runAsScript } from "./lib/ts-guard-utils.mts";
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. Default: HEAD with --staged, otherwise ${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 (
/\.(?:[cm]?[jt]s|[jt]sx)$/u.test(normalizedPath) &&
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?: string;
failOnFindings: boolean;
head: string;
help: boolean;
json: boolean;
noMergeBase: boolean;
staged: boolean;
} = {
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 {
const base = args.base ?? DEFAULT_BASE_REF;
let useMergeBase = !args.noMergeBase;
if (useMergeBase && !args.staged) {
const mergeBase = spawnSync("git", ["merge-base", 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 ? `${base}...${args.head}` : `${base}..${args.head}`;
const diffArgs = [
"diff",
...(args.staged ? ["--cached", ...(args.base ? [args.base] : [])] : [range]),
"--diff-filter=ACMR",
];
const readGitDiff = (...options: string[]) =>
execFileSync("git", ["--literal-pathspecs", ...diffArgs, ...options], {
cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
// Select paths before reading patches so unrelated generated output cannot
// exhaust the diff buffer. Both rename/copy endpoints preserve added-line scope.
const fields = readGitDiff("--name-status", "-z", "--").split("\0");
const paths = new Set<string>();
for (let index = 0; index < fields.length;) {
const status = fields[index++];
const from = fields[index++];
if (!status || !from) {
break;
}
const to = status.startsWith("R") || status.startsWith("C") ? fields[index++] : from;
if (to && shouldInspectFile(to)) {
paths.add(from);
paths.add(to);
}
}
return paths.size > 0 ? readGitDiff("--unified=0", "--", ...paths) : "";
}
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.
try {
return args.staged
? execFileSync("git", ["show", `:${filePath}`], {
cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
: fs.readFileSync(path.join(cwd, filePath), "utf8");
} catch {
return "";
}
}
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 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 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,
sourceFile: ts.SourceFile,
addedLineNumbers: Set<number>,
): TempCreationFinding[] {
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)),
});
}
node.forEachChild(visit);
};
visit(sourceFile);
return findings;
}
export function collectTempCreationFindingsFromDiff(
diffText: string,
options: {
fileTextByPath?: Map<string, string> | Record<string, string>;
readFile?: (filePath: string) => string | null | undefined;
} = {},
): TempCreationFinding[] {
const findings: TempCreationFinding[] = [];
const addedLinesByFile = new Map<string, Set<number>>();
const fileTextByPath =
options.fileTextByPath instanceof Map
? options.fileTextByPath
: new Map(Object.entries(options.fileTextByPath ?? {}));
let currentFile: string | null = null;
let currentLine = 0;
let allowNextLine: AllowNextLine | null = null;
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);
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 lines = addedLinesByFile.get(currentFile) ?? new Set<number>();
lines.add(currentLine);
addedLinesByFile.set(currentFile, lines);
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;
}
}
const parser = createNativeTypeScriptParser();
try {
for (const [file, addedLines] of addedLinesByFile) {
if (!shouldInspectManualHelperUsage(file)) {
continue;
}
const sourceText =
(fileTextByPath.has(file) ? fileTextByPath.get(file) : options.readFile?.(file)) ?? "";
if (!sourceText) {
continue;
}
const sourceFile = parser.parseSourceFile(file, sourceText);
findings.push(...findManualHelperUsageFindings(file, sourceFile, addedLines));
}
} finally {
parser.close();
}
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;
});