mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 22:16:11 +00:00
210 lines
6.0 KiB
JavaScript
210 lines
6.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// Type Suppression Inventory reports unchecked any casts and expected TypeScript errors.
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import ts from "typescript";
|
|
import { collectFilesSync, isCodeFile, toPosixPath } from "./check-file-utils.js";
|
|
|
|
type TypeSuppressionKind = "as-any" | "expect-error" | "type-assertion-any";
|
|
|
|
export type TypeSuppressionFinding = {
|
|
excerpt: string;
|
|
file: string;
|
|
kind: TypeSuppressionKind;
|
|
line: number;
|
|
};
|
|
|
|
export type TypeSuppressionReport = {
|
|
findings: TypeSuppressionFinding[];
|
|
scannedFileCount: number;
|
|
schemaVersion: 1;
|
|
summary: {
|
|
findingCount: number;
|
|
kindCounts: Record<TypeSuppressionKind, number>;
|
|
scannedFileCount: number;
|
|
touchedFileCount: number;
|
|
};
|
|
};
|
|
|
|
const DEFAULT_SCAN_ROOTS = ["src", "test", "extensions", "packages", "ui", "scripts"];
|
|
const DEFAULT_SKIPPED_DIR_NAMES = new Set([
|
|
".artifacts",
|
|
".generated",
|
|
"coverage",
|
|
"dist",
|
|
"fixtures",
|
|
"node_modules",
|
|
"vendor",
|
|
]);
|
|
|
|
function listGitFiles(repoRoot: string, roots: readonly string[]): string[] | null {
|
|
try {
|
|
const stdout = execFileSync("git", ["-C", repoRoot, "ls-files", "--", ...roots], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
return stdout.split(/\r?\n/u).filter(Boolean);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function listCandidateFiles(repoRoot: string, roots: readonly string[]): string[] {
|
|
const gitFiles = listGitFiles(repoRoot, roots);
|
|
const relativeFiles =
|
|
gitFiles ??
|
|
roots.flatMap((root) => {
|
|
const absoluteRoot = path.join(repoRoot, root);
|
|
if (!fs.existsSync(absoluteRoot)) {
|
|
return [];
|
|
}
|
|
return collectFilesSync(absoluteRoot, {
|
|
includeFile: isCodeFile,
|
|
skipDirNames: DEFAULT_SKIPPED_DIR_NAMES,
|
|
}).map((filePath) => toPosixPath(path.relative(repoRoot, filePath)));
|
|
});
|
|
return relativeFiles
|
|
.filter((file) => {
|
|
const pathSegments = toPosixPath(file).split("/");
|
|
return (
|
|
/\.[cm]?tsx?$/u.test(file) &&
|
|
!file.endsWith(".d.ts") &&
|
|
!pathSegments.some((segment) => DEFAULT_SKIPPED_DIR_NAMES.has(segment))
|
|
);
|
|
})
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function sourceKindForFile(file: string): ts.ScriptKind {
|
|
return file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
}
|
|
|
|
function addAnyCastFindings(
|
|
sourceFile: ts.SourceFile,
|
|
file: string,
|
|
findings: TypeSuppressionFinding[],
|
|
): void {
|
|
const visit = (node: ts.Node): void => {
|
|
const kind =
|
|
ts.isAsExpression(node) && node.type.kind === ts.SyntaxKind.AnyKeyword
|
|
? "as-any"
|
|
: ts.isTypeAssertionExpression(node) && node.type.kind === ts.SyntaxKind.AnyKeyword
|
|
? "type-assertion-any"
|
|
: null;
|
|
if (kind) {
|
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
findings.push({
|
|
excerpt: node.getText(sourceFile).replace(/\s+/gu, " ").trim(),
|
|
file,
|
|
kind,
|
|
line: line + 1,
|
|
});
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
}
|
|
|
|
function addExpectErrorFindings(
|
|
sourceFile: ts.SourceFile,
|
|
file: string,
|
|
findings: TypeSuppressionFinding[],
|
|
): void {
|
|
const source = sourceFile.getFullText();
|
|
const scanner = ts.createScanner(
|
|
ts.ScriptTarget.Latest,
|
|
false,
|
|
file.endsWith(".tsx") ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard,
|
|
source,
|
|
);
|
|
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
if (
|
|
token !== ts.SyntaxKind.SingleLineCommentTrivia &&
|
|
token !== ts.SyntaxKind.MultiLineCommentTrivia
|
|
) {
|
|
continue;
|
|
}
|
|
const comment = scanner.getTokenText();
|
|
const markerPattern = /@ts-expect-error[^\r\n]*/gu;
|
|
for (const match of comment.matchAll(markerPattern)) {
|
|
const position = scanner.getTokenPos() + (match.index ?? 0);
|
|
const line = sourceFile.getLineAndCharacterOfPosition(position).line;
|
|
findings.push({
|
|
excerpt: match[0].trim(),
|
|
file,
|
|
kind: "expect-error",
|
|
line: line + 1,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function collectTypeSuppressionReport(params: {
|
|
files?: readonly string[];
|
|
repoRoot: string;
|
|
roots?: readonly string[];
|
|
}): TypeSuppressionReport {
|
|
const files = [
|
|
...(params.files ?? listCandidateFiles(params.repoRoot, params.roots ?? DEFAULT_SCAN_ROOTS)),
|
|
]
|
|
.map(toPosixPath)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
const findings: TypeSuppressionFinding[] = [];
|
|
|
|
for (const file of files) {
|
|
const absolutePath = path.join(params.repoRoot, file);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
continue;
|
|
}
|
|
const source = fs.readFileSync(absolutePath, "utf8");
|
|
const sourceFile = ts.createSourceFile(
|
|
file,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
sourceKindForFile(file),
|
|
);
|
|
addAnyCastFindings(sourceFile, file, findings);
|
|
addExpectErrorFindings(sourceFile, file, findings);
|
|
}
|
|
|
|
findings.sort(
|
|
(left, right) =>
|
|
left.file.localeCompare(right.file) ||
|
|
left.line - right.line ||
|
|
left.kind.localeCompare(right.kind),
|
|
);
|
|
const kindCounts: Record<TypeSuppressionKind, number> = {
|
|
"as-any": 0,
|
|
"expect-error": 0,
|
|
"type-assertion-any": 0,
|
|
};
|
|
for (const finding of findings) {
|
|
kindCounts[finding.kind] = kindCounts[finding.kind] + 1;
|
|
}
|
|
|
|
return {
|
|
findings,
|
|
scannedFileCount: files.length,
|
|
schemaVersion: 1,
|
|
summary: {
|
|
findingCount: findings.length,
|
|
kindCounts,
|
|
scannedFileCount: files.length,
|
|
touchedFileCount: new Set(findings.map((finding) => finding.file)).size,
|
|
},
|
|
};
|
|
}
|
|
|
|
function main(): void {
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
process.stdout.write(`${JSON.stringify(collectTypeSuppressionReport({ repoRoot }), null, 2)}\n`);
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main();
|
|
}
|