perf: speed type suppression inventory (#107874)

This commit is contained in:
Peter Steinberger
2026-07-14 18:08:33 -07:00
committed by GitHub
parent 75d029dae2
commit 1fd4009e41
2 changed files with 88 additions and 21 deletions
+79 -18
View File
@@ -33,7 +33,61 @@ export type TypeSuppressionReport = {
};
};
const TYPE_SUPPRESSION_CANDIDATE_PATTERN = /\bany\b|@ts-expect-error/u;
const TYPE_SUPPRESSION_WHITESPACE_PATTERN = /\s/u;
function skipTypeScriptTrivia(source: string, start: number): number {
let offset = start;
while (offset < source.length) {
const character = source[offset];
if (character && TYPE_SUPPRESSION_WHITESPACE_PATTERN.test(character)) {
offset += 1;
continue;
}
if (source.startsWith("/*", offset)) {
const commentEnd = source.indexOf("*/", offset + 2);
offset = commentEnd === -1 ? source.length : commentEnd + 2;
continue;
}
if (source.startsWith("//", offset)) {
offset += 2;
while (offset < source.length && !/[\r\n\u2028\u2029]/u.test(source[offset] ?? "")) {
offset += 1;
}
continue;
}
break;
}
return offset;
}
function isAnyKeywordAt(source: string, offset: number): boolean {
return (
source.startsWith("any", offset) && !/[A-Za-z0-9_$]/u.test(source[offset + "any".length] ?? "")
);
}
function hasTypeSuppressionTextCandidate(source: string): boolean {
if (source.includes("@ts-expect-error")) {
return true;
}
for (const match of source.matchAll(/\bas\b/gu)) {
if (
isAnyKeywordAt(source, skipTypeScriptTrivia(source, (match.index ?? 0) + match[0].length))
) {
return true;
}
}
for (let offset = source.indexOf("<"); offset !== -1; offset = source.indexOf("<", offset + 1)) {
const anyOffset = skipTypeScriptTrivia(source, offset + 1);
if (
isAnyKeywordAt(source, anyOffset) &&
source[skipTypeScriptTrivia(source, anyOffset + "any".length)] === ">"
) {
return true;
}
}
return false;
}
function listCandidateFiles(repoRoot: string, roots: readonly string[]): string[] {
return listRepoFilesSync(repoRoot, {
@@ -81,23 +135,30 @@ function addExpectErrorFindings(
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;
if (!source.includes("@ts-expect-error")) {
return;
}
const comments = new Map<number, ts.CommentRange>();
const addComments = (ranges: readonly ts.CommentRange[] | undefined): void => {
for (const range of ranges ?? []) {
comments.set(range.pos, range);
}
const comment = scanner.getTokenText();
};
const visit = (node: ts.Node): void => {
addComments(ts.getLeadingCommentRanges(source, node.pos));
addComments(ts.getTrailingCommentRanges(source, node.end));
for (const child of node.getChildren(sourceFile)) {
visit(child);
}
};
visit(sourceFile);
addComments(ts.getLeadingCommentRanges(source, sourceFile.endOfFileToken.pos));
for (const range of comments.values()) {
const comment = source.slice(range.pos, range.end);
const markerPattern = /@ts-expect-error[^\r\n]*/gu;
for (const match of comment.matchAll(markerPattern)) {
const position = scanner.getTokenPos() + (match.index ?? 0);
const position = range.pos + (match.index ?? 0);
const line = sourceFile.getLineAndCharacterOfPosition(position).line;
findings.push({
excerpt: match[0].trim(),
@@ -127,9 +188,9 @@ export function collectTypeSuppressionReport(params: {
continue;
}
const source = fs.readFileSync(absolutePath, "utf8");
// Full AST parsing dominates the repository ratchet. Every reported construct
// contains one of these literal markers, so marker-free files are safe to skip.
if (!TYPE_SUPPRESSION_CANDIDATE_PATTERN.test(source)) {
// Full AST parsing dominates the repository ratchet. This syntax-shaped text
// gate keeps false positives cheap; the AST remains the source of truth.
if (!hasTypeSuppressionTextCandidate(source)) {
continue;
}
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest);
@@ -30,8 +30,14 @@ describe("type suppression inventory", () => {
const fixtureRoot = createFixture({
"src/example.ts": `
const prose = "as any and @ts-expect-error";
const interpolated = \`value: \${value}\`;
const first = value as any;
const second = <any>value;
const third = value as /* preserved trivia */ any;
const fourth = </* preserved trivia */ any>value;
const fifth = value as // preserved line trivia
any;
const typeOnly: any = value;
// @ts-expect-error invalid contract fixture
consume({ invalid: true });
`,
@@ -44,11 +50,11 @@ describe("type suppression inventory", () => {
});
expect(report.summary).toMatchObject({
findingCount: 3,
findingCount: 6,
kindCounts: {
"as-any": 1,
"as-any": 3,
"expect-error": 1,
"type-assertion-any": 1,
"type-assertion-any": 2,
},
scannedFileCount: 2,
touchedFileCount: 1,