authorgravatar for ethorelli@gmail.comErik Thorelli <ethorelli@gmail.com> 2026-10-05 19:29:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 02:29:26+00:00
log31d0c8a4f47ead36c9a63ac4d3bf27b55c05095a
treec9367413d6eb2d0c9d6da5e143a5504292459b51
parent6262e5f50081845bb247e5af2fc81b068c8ae893
signature Signed by PGP key B5690EEEBB952194

chore(lint): require a reason on every lint and type-checker suppression (#16294)


7 files changed, 194 insertions(+), 4 deletions(-)

apps/server/src/project/ProjectSetupScriptRunner.ts+2-2
......@@ -144,11 +144,11 @@ function stripTerminalControl(text: string): string {
144144 return (
145145 text
146146 .replace(
147 // eslint-disable-next-line no-control-regex
147 // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with ESC.
148148 /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g,
149149 "",
150150 )
151 // eslint-disable-next-line no-control-regex
151 // eslint-disable-next-line no-control-regex -- removing control characters is the point.
152152 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
153153 );
154154}
apps/web/src/components/files/BrowserDocumentFrame.tsx+1-1
......@@ -35,7 +35,7 @@ export function BrowserDocumentFrame(props: {
3535}) {
3636 const className = "min-h-0 flex-1 border-0 bg-white";
3737 return props.pdf ? (
38 // oxlint-disable-next-line react/iframe-missing-sandbox
38 // oxlint-disable-next-line react/iframe-missing-sandbox -- the built-in PDF viewer needs an unsandboxed frame.
3939 <iframe
4040 key={props.src}
4141 src={`${props.src}${PDF_VIEWER_FRAGMENT}`}
oxlint-plugin-t3code/index.ts+2
......@@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e
99import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
1010import noTestInLoop from "./rules/no-test-in-loop.ts";
1111import noUnscopedHas from "./rules/no-unscoped-has.ts";
12import requireSuppressionReason from "./rules/require-suppression-reason.ts";
1213
1314export default definePlugin({
1415 meta: {
......@@ -24,5 +25,6 @@ export default definePlugin({
2425 "no-native-title-tooltip": noNativeTitleTooltip,
2526 "no-test-in-loop": noTestInLoop,
2627 "no-unscoped-has": noUnscopedHas,
28 "require-suppression-reason": requireSuppressionReason,
2729 },
2830});
oxlint-plugin-t3code/rules/require-suppression-reason.test.ts created+117
......@@ -0,0 +1,117 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/require-suppression-reason", {
6 filename: "fixture.tsx",
7});
8
9describe("t3code/require-suppression-reason", () => {
10 rule.valid(
11 "allows a directive with a -- reason",
12 `
13 // oxlint-disable-next-line no-control-regex -- ANSI sequences start with ESC.
14 const ansi = /\\x1b\\[/;
15 /* eslint-disable no-console -- this script reports to the terminal */
16 `,
17 );
18
19 rule.valid(
20 "allows a directive explained by the comment above",
21 `
22 // Positions are stable for a given string.
23 // oxlint-disable-next-line react/no-array-index-key
24 const items = ["a"].map((item, index) => <li key={index}>{item}</li>);
25 `,
26 );
27
28 rule.valid(
29 "allows stacked directives under one explanation",
30 `
31 // The fixture is invalid on purpose.
32 // @ts-expect-error
33 // oxlint-disable-next-line no-unused-vars
34 const value: number = "text";
35 `,
36 );
37
38 rule.valid(
39 "allows a TypeScript directive followed by its reason",
40 `
41 // @ts-expect-error the fixture passes a string on purpose
42 const count: number = "text";
43 `,
44 );
45
46 rule.valid(
47 "allows a directive in a JSX comment with a reason",
48 `const el = <div>{/* oxlint-disable-next-line react/no-danger -- sanitized above */}</div>;`,
49 );
50
51 rule.valid("ignores enable directives", `/* oxlint-enable no-console */`);
52
53 rule.invalid(
54 "reports a lint directive without a reason",
55 `
56 // oxlint-disable-next-line no-control-regex
57 const ansi = /\\x1b\\[/;
58 `,
59 (output) => {
60 assert.match(output, /end the directive with `-- reason`/);
61 },
62 );
63
64 rule.invalid(
65 "reports a file-level directive without a reason",
66 `/* oxlint-disable eslint/no-useless-escape */`,
67 );
68
69 rule.invalid(
70 "reports a TypeScript directive without a reason",
71 `
72 // @ts-expect-error
73 const count: number = "text";
74 `,
75 (output) => {
76 assert.match(output, /write the reason after the directive/);
77 },
78 );
79
80 rule.invalid(
81 "does not count a comment separated by a blank line",
82 `
83 // Positions are stable for a given string.
84
85 // oxlint-disable-next-line react/no-array-index-key
86 const items = ["a"].map((item, index) => <li key={index}>{item}</li>);
87 `,
88 );
89
90 rule.valid(
91 "allows an empty comment line between the reason and the directive",
92 `
93 // Positions are stable for a given string.
94 //
95 // oxlint-disable-next-line react/no-array-index-key
96 const items = ["a"].map((item, index) => <li key={index}>{item}</li>);
97 `,
98 );
99
100 rule.invalid(
101 "does not count an empty comment above",
102 `
103 //
104 /* */
105 // oxlint-disable-next-line react/no-array-index-key
106 const items = ["a"].map((item, index) => <li key={index}>{item}</li>);
107 `,
108 );
109
110 rule.invalid(
111 "does not count a trailing comment on the code line above",
112 `
113 const first = 1; // the first value
114 const second = 2; // oxlint-disable-line no-unused-vars
115 `,
116 );
117});
oxlint-plugin-t3code/rules/require-suppression-reason.ts created+70
......@@ -0,0 +1,70 @@
1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3// Comments that switch a diagnostic off or on. None of them explains another.
4const DIRECTIVE_PATTERN =
5 /^\s*\*?\s*(?:(?:oxlint|eslint)-(?:disable|enable)|@ts-(?:expect-error|ignore|nocheck)|@effect-diagnostics)/u;
6// TypeScript's own directives; whatever follows one is its reason.
7const TS_DIRECTIVE_PATTERN = /^\s*\*?\s*@ts-(?:expect-error|ignore|nocheck)(?![\w-])(.*)$/su;
8const EMPTY_COMMENT_PATTERN = /^[\s*]*$/u;
9
10/**
11 * Reports a directive that disables a lint rule or the type checker without saying why. The reason
12 * is a `-- reason` suffix (text after the directive, for `@ts-*`) or a comment on the line above.
13 */
14export default defineRule({
15 meta: {
16 type: "suggestion",
17 docs: {
18 description: "Require every lint or type-checker suppression to say why.",
19 },
20 },
21 create(context) {
22 const { sourceCode } = context;
23 return {
24 Program() {
25 const comments = sourceCode.getAllComments();
26 const ownLineCommentsByEndLine = new Map<number, ESTree.Comment>();
27 for (const comment of comments) {
28 const { line, column } = comment.loc.start;
29 if ((sourceCode.lines[line - 1] ?? "").slice(0, column).trim() === "") {
30 ownLineCommentsByEndLine.set(comment.loc.end.line, comment);
31 }
32 }
33
34 // A comment directly above explains a directive, even across stacked directives and empty
35 // comment lines.
36 const explainedAbove = (directive: ESTree.Comment) => {
37 let above = ownLineCommentsByEndLine.get(directive.loc.start.line - 1);
38 while (
39 above &&
40 (DIRECTIVE_PATTERN.test(above.value) || EMPTY_COMMENT_PATTERN.test(above.value))
41 ) {
42 above = ownLineCommentsByEndLine.get(above.loc.start.line - 1);
43 }
44 return above !== undefined;
45 };
46
47 for (const directive of sourceCode.getDisableDirectives().directives) {
48 if (directive.type === "enable" || directive.justification?.trim()) continue;
49 if (explainedAbove(directive.node)) continue;
50 context.report({
51 loc: directive.node.loc,
52 message:
53 "Say why: end the directive with `-- reason` or put a comment on the line above.",
54 });
55 }
56
57 for (const comment of comments) {
58 const after = TS_DIRECTIVE_PATTERN.exec(comment.value)?.[1];
59 if (after === undefined || after.replace(/^[\s:-]+/u, "").trim()) continue;
60 if (explainedAbove(comment)) continue;
61 context.report({
62 loc: comment.loc,
63 message:
64 "Say why: write the reason after the directive or in a comment on the line above.",
65 });
66 }
67 },
68 };
69 },
70});
packages/shared/src/qrCode.ts+1-1
......@@ -1,4 +1,4 @@
1/* oxlint-disable eslint/no-useless-escape */
1/* oxlint-disable eslint/no-useless-escape -- vendored Project Nayuki code, kept as upstream wrote it. */
22/*
33 * QR Code generator library (TypeScript)
44 *
vite.config.ts+1
......@@ -170,6 +170,7 @@ export default defineConfig({
170170 "t3code/no-test-in-loop": "error",
171171 "t3code/no-unscoped-has": "error",
172172 "t3code/namespace-node-imports": "error",
173 "t3code/require-suppression-reason": "error",
173174 },
174175 overrides: [
175176 {