diff --git a/crates/product/ironclaw_webui/frontend/scripts/check-source-conventions.ts b/crates/product/ironclaw_webui/frontend/scripts/check-source-conventions.ts index 1493756b2d..38c86428a2 100644 --- a/crates/product/ironclaw_webui/frontend/scripts/check-source-conventions.ts +++ b/crates/product/ironclaw_webui/frontend/scripts/check-source-conventions.ts @@ -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, ): 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; diff --git a/crates/product/ironclaw_webui/frontend/src/test/source-conventions.test.ts b/crates/product/ironclaw_webui/frontend/src/test/source-conventions.test.ts index 2af2e9f538..cc5c235c7e 100644 --- a/crates/product/ironclaw_webui/frontend/src/test/source-conventions.test.ts +++ b/crates/product/ironclaw_webui/frontend/src/test/source-conventions.test.ts @@ -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,