From 1fd4009e41c1d6b217c4410953e86199e7cb25f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 18:08:33 -0700 Subject: [PATCH] perf: speed type suppression inventory (#107874) --- scripts/type-suppression-inventory.ts | 97 +++++++++++++++---- .../type-suppression-inventory.test.ts | 12 ++- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/scripts/type-suppression-inventory.ts b/scripts/type-suppression-inventory.ts index 71fee1ebc68..b6fca89d3a9 100644 --- a/scripts/type-suppression-inventory.ts +++ b/scripts/type-suppression-inventory.ts @@ -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(); + 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); diff --git a/test/scripts/type-suppression-inventory.test.ts b/test/scripts/type-suppression-inventory.test.ts index 918a470db69..4c734a05ad0 100644 --- a/test/scripts/type-suppression-inventory.test.ts +++ b/test/scripts/type-suppression-inventory.test.ts @@ -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 = value; + const third = value as /* preserved trivia */ any; + const fourth = 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,