fix(webui): match effective TypeScript suppressions

This commit is contained in:
italic-jinxin
2026-09-02 18:08:43 +08:00
parent a99e7e3079
commit 06bbee1486
2 changed files with 82 additions and 19 deletions

View File

@@ -16,6 +16,10 @@ const JAVASCRIPT_FAMILY_EXTENSIONS = new Set([
]);
const TYPESCRIPT_EXTENSIONS = new Set([".ts", ".tsx"]);
const EXPLICIT_RELATIVE_EXTENSION = /\.(?:[cm]?[jt]sx?)(?:[?#].*)?$/i;
const SINGLE_LINE_CHECK_PRAGMA =
/^\/\/\/?\s*@(ts-check|ts-nocheck)(?:(?:[^\S\r\n]|:).*)?$/i;
const SINGLE_LINE_IGNORE_DIRECTIVE = /^\/\/\/?\s*@ts-ignore/;
const MULTI_LINE_IGNORE_DIRECTIVE = /^(?:\/|\*)*\s*@ts-ignore/;
export type ConventionViolationKind =
| "explicit-relative-extension"
@@ -86,6 +90,41 @@ function compareViolations(left: ConventionViolation, right: ConventionViolation
);
}
function effectiveNocheckPosition(sourceText: string): number | undefined {
let position: number | undefined;
for (const range of ts.getLeadingCommentRanges(sourceText, 0) ?? []) {
if (range.kind !== ts.SyntaxKind.SingleLineCommentTrivia) continue;
const comment = sourceText.slice(range.pos, range.end);
const match = SINGLE_LINE_CHECK_PRAGMA.exec(comment);
if (!match) continue;
position =
match[1].toLowerCase() === "ts-nocheck"
? range.pos + comment.indexOf("@")
: undefined;
}
return position;
}
function effectiveIgnoreOffset(
comment: string,
token: ts.SyntaxKind,
): number | undefined {
if (token === ts.SyntaxKind.SingleLineCommentTrivia) {
return SINGLE_LINE_IGNORE_DIRECTIVE.test(comment)
? comment.indexOf("@")
: undefined;
}
const lastLineStart = Math.max(
comment.lastIndexOf("\n"),
comment.lastIndexOf("\r"),
) + 1;
const lastLine = comment.slice(lastLineStart);
return MULTI_LINE_IGNORE_DIRECTIVE.test(lastLine)
? lastLineStart + lastLine.indexOf("@")
: undefined;
}
function checkTypeScriptSuppressions(
filePath: string,
sourceFile: ts.SourceFile,
@@ -94,13 +133,23 @@ function checkTypeScriptSuppressions(
seenLegacyTsNocheckFiles?: Set<string>,
): ConventionViolation[] {
const violations: ConventionViolation[] = [];
const nocheckPosition = effectiveNocheckPosition(sourceText);
if (nocheckPosition !== undefined) {
const line =
sourceFile.getLineAndCharacterOfPosition(nocheckPosition).line + 1;
if (legacyTsNocheckFiles.has(filePath)) {
seenLegacyTsNocheckFiles?.add(filePath);
} else {
violations.push({ file: filePath, kind: "unbaselined-ts-nocheck", line });
}
}
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
false,
sourceFile.languageVariant,
sourceText,
);
let remainingLegacyNocheck = legacyTsNocheckFiles.has(filePath) ? 1 : 0;
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
if (
@@ -111,19 +160,11 @@ function checkTypeScriptSuppressions(
}
const comment = scanner.getTokenText();
const directivePattern = /@ts-(nocheck|ignore)\b/g;
for (const match of comment.matchAll(directivePattern)) {
const position = scanner.getTokenPos() + (match.index ?? 0);
const line = sourceFile.getLineAndCharacterOfPosition(position).line + 1;
if (match[1] === "ignore") {
violations.push({ file: filePath, kind: "prohibited-ts-ignore", line });
} else if (remainingLegacyNocheck > 0) {
remainingLegacyNocheck -= 1;
seenLegacyTsNocheckFiles?.add(filePath);
} else {
violations.push({ file: filePath, kind: "unbaselined-ts-nocheck", line });
}
}
const ignoreOffset = effectiveIgnoreOffset(comment, token);
if (ignoreOffset === undefined) continue;
const position = scanner.getTokenPos() + ignoreOffset;
const line = sourceFile.getLineAndCharacterOfPosition(position).line + 1;
violations.push({ file: filePath, kind: "prohibited-ts-ignore", line });
}
return violations;

View File

@@ -95,7 +95,7 @@ test("rejects unbaselined TypeScript suppression directives without blocking exp
);
});
test("allows one legacy nocheck only in its baselined file", () => {
test("allows an effective legacy nocheck only in its baselined file", () => {
const baseline = new Set(["legacy.ts"]);
assert.deepEqual(checkSourceFile("legacy.ts", "// @ts-nocheck\nexport {};\n", baseline), []);
@@ -104,11 +104,11 @@ test("allows one legacy nocheck only in its baselined file", () => {
"legacy.ts",
"/* @ts-nocheck */\n// @ts-nocheck\nexport {};\n",
baseline,
).map(({ kind, line }) => ({ kind, line })),
[{ kind: "unbaselined-ts-nocheck", line: 2 }],
),
[],
);
assert.deepEqual(
checkSourceFile("new.ts", "/* @ts-nocheck */\nexport {};\n", baseline).map(
checkSourceFile("new.ts", "// @ts-nocheck\nexport {};\n", baseline).map(
({ kind, line }) => ({ kind, line }),
),
[{ kind: "unbaselined-ts-nocheck", line: 1 }],
@@ -125,6 +125,25 @@ test("does not treat suppression text inside strings as a directive", () => {
);
});
test("matches only suppression directives that TypeScript applies", () => {
const source = [
"// docs: @ts-nocheck",
"// @ts-nocheck-disabled",
"/* @ts-nocheck */",
"const trailing = 1; // @ts-nocheck",
"// docs: @ts-ignore",
].join("\n");
assert.deepEqual(checkSourceFile("feature.ts", source), []);
assert.deepEqual(
checkSourceFile(
"checked.ts",
"// @ts-nocheck\n// @ts-check\nconst value: string = 1;\n",
),
[],
);
});
test("recursively scans source trees and reports stable relative paths", () => {
const root = mkdtempSync(join(tmpdir(), "ironclaw-source-conventions-"));
temporaryRoots.push(root);
@@ -153,7 +172,10 @@ test("recursively scans source trees and reports stable relative paths", () => {
test("rejects stale nocheck baseline entries", () => {
const root = mkdtempSync(join(tmpdir(), "ironclaw-source-conventions-"));
temporaryRoots.push(root);
writeFileSync(join(root, "cleaned-up.ts"), "export {};\n");
writeFileSync(
join(root, "cleaned-up.ts"),
"// docs: @ts-nocheck\nexport {};\n",
);
const violations = checkSourceTree(
root,