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 {...@@ -144,11 +144,11 @@ function stripTerminalControl(text: string): string {
144 return (144 return (
145 text145 text
146 .replace(146 .replace(
147 // eslint-disable-next-line no-control-regex147 // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with ESC.
148 /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g,148 /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g,
149 "",149 "",
150 )150 )
151 // eslint-disable-next-line no-control-regex151 // eslint-disable-next-line no-control-regex -- removing control characters is the point.
152 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")152 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
153 );153 );
154}154}
apps/web/src/components/files/BrowserDocumentFrame.tsx+1-1
...@@ -35,7 +35,7 @@ export function BrowserDocumentFrame(props: {...@@ -35,7 +35,7 @@ export function BrowserDocumentFrame(props: {
35}) {35}) {
36 const className = "min-h-0 flex-1 border-0 bg-white";36 const className = "min-h-0 flex-1 border-0 bg-white";
37 return props.pdf ? (37 return props.pdf ? (
38 // oxlint-disable-next-line react/iframe-missing-sandbox38 // oxlint-disable-next-line react/iframe-missing-sandbox -- the built-in PDF viewer needs an unsandboxed frame.
39 <iframe39 <iframe
40 key={props.src}40 key={props.src}
41 src={`${props.src}${PDF_VIEWER_FRAGMENT}`}41 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...@@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e
9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noTestInLoop from "./rules/no-test-in-loop.ts";10import noTestInLoop from "./rules/no-test-in-loop.ts";
11import noUnscopedHas from "./rules/no-unscoped-has.ts";11import noUnscopedHas from "./rules/no-unscoped-has.ts";
12import requireSuppressionReason from "./rules/require-suppression-reason.ts";
1213
13export default definePlugin({14export default definePlugin({
14 meta: {15 meta: {
...@@ -24,5 +25,6 @@ export default definePlugin({...@@ -24,5 +25,6 @@ export default definePlugin({
24 "no-native-title-tooltip": noNativeTitleTooltip,25 "no-native-title-tooltip": noNativeTitleTooltip,
25 "no-test-in-loop": noTestInLoop,26 "no-test-in-loop": noTestInLoop,
26 "no-unscoped-has": noUnscopedHas,27 "no-unscoped-has": noUnscopedHas,
28 "require-suppression-reason": requireSuppressionReason,
27 },29 },
28});30});
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,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. */
2/*2/*
3 * QR Code generator library (TypeScript)3 * QR Code generator library (TypeScript)
4 *4 *
vite.config.ts+1
...@@ -170,6 +170,7 @@ export default defineConfig({...@@ -170,6 +170,7 @@ export default defineConfig({
170 "t3code/no-test-in-loop": "error",170 "t3code/no-test-in-loop": "error",
171 "t3code/no-unscoped-has": "error",171 "t3code/no-unscoped-has": "error",
172 "t3code/namespace-node-imports": "error",172 "t3code/namespace-node-imports": "error",
173 "t3code/require-suppression-reason": "error",
173 },174 },
174 overrides: [175 overrides: [
175 {176 {