From 7e42410b0153075bf543c171d202a533fb94462c Mon Sep 17 00:00:00 2001 From: Lloyd Evans Date: Thu, 8 Oct 2026 23:51:58 +0100 Subject: [PATCH 1/2] Limit comment lines to the print width --- CONTRIBUTING.md | 1 + eslint.config.mts | 18 +++++++ scripts/lint/comment-width.test.mts | 81 +++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 scripts/lint/comment-width.test.mts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b40253c..264bbbc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,7 @@ See [README.md](README.md) for requirements and commands. - Keep comments brief and do not over-explain. - A line comment belongs directly above the code it describes, inside that logical block. Preserve the separating blank line above the comment; the comment never takes its place. +- Wrap each comment paragraph at the last word that fits within Prettier's `printWidth` in `package.json`. Prettier does not reflow comments. Preserve paragraph and JSDoc tag boundaries, unbreakable text, disable comments and layouts whose spacing carries meaning. - Document purpose, parameters, results and failures with JSDoc, without repeating TypeScript types. - Inline callbacks need no JSDoc. - A function assigned to a local variable needs no JSDoc, as an inline callback needs none. Where a returned function's contract is a documented function type, assign the function to a variable typed with it and return the variable, rather than repeating the type's docs; keep anything specific to the implementation in a plain line comment. diff --git a/eslint.config.mts b/eslint.config.mts index 0725595..198dc21 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -2,9 +2,14 @@ import { defineConfig } from "eslint/config"; import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; import globals from "globals"; +import type { Options } from "prettier"; +import manifest from "./package.json" with { type: "json" }; import { documentationConfig } from "./scripts/lint/jsdoc-config.mts"; import { importBoundaries, vitestImportCalls } from "./scripts/lint/import-boundaries.mts"; +/** Read optional formatter settings alongside the required print width. */ +const prettierOptions: Options = manifest.prettier; + /** Combine type-aware checks with documentation and member-order rules. */ export default defineConfig( { ignores: ["dist/", "coverage/", "reports/", ".stryker-tmp/"] }, @@ -13,6 +18,19 @@ export default defineConfig( tseslint.configs.stylisticTypeChecked, { rules: { + "@stylistic/max-len": [ + "error", + { + // Prettier owns code width, including lines with trailing comments. + code: Number.MAX_SAFE_INTEGER, + comments: manifest.prettier.printWidth, + // Use Prettier's default when the project does not set a tab width. + tabWidth: prettierOptions.tabWidth ?? 2, + ignoreUrls: true, + ignorePattern: + "^\\s*(?://\\s*eslint-disable-(?:next-line|line)|/\\*\\s*eslint-disable(?:-next-line|-line)?)(?:\\s|\\*/|$)", + }, + ], "@typescript-eslint/member-ordering": "error", "arrow-body-style": ["error", "as-needed"], "one-var": ["error", "never"], diff --git a/scripts/lint/comment-width.test.mts b/scripts/lint/comment-width.test.mts new file mode 100644 index 0000000..2bad165 --- /dev/null +++ b/scripts/lint/comment-width.test.mts @@ -0,0 +1,81 @@ +import { ESLint } from "eslint"; +import { format } from "prettier"; +import tseslint from "typescript-eslint"; +import { expect, test } from "vitest"; +import manifest from "../../package.json" with { type: "json" }; + +const eslint = new ESLint({ overrideConfig: tseslint.configs.disableTypeChecked }); +const width = manifest.prettier.printWidth; + +function paragraph(length: number) { + return `${"word ".repeat(length).slice(0, length - 1)}.`; +} + +async function diagnostics(source: string) { + const results = await eslint.lintText(source, { filePath: "scripts/lint/comment-width-example.mts" }); + expect(results).toHaveLength(1); + expect(results[0]?.fatalErrorCount).toBe(0); + + return results.flatMap((result) => result.messages.filter((message) => message.ruleId === "@stylistic/max-len")); +} + +test.each([ + { name: "line comment", prefix: "// ", suffix: "" }, + { name: "JSDoc line", prefix: "/**\n * ", suffix: "\n */" }, +])("limits a $name to the configured print width", async ({ prefix, suffix }) => { + const text = paragraph(width - 3); + expect(await diagnostics(`${prefix}${text}${suffix}`)).toEqual([]); + expect(await diagnostics(`${prefix}${text}.${suffix}`)).toEqual([expect.objectContaining({ messageId: "maxComment" })]); +}); + +test("counts indentation tabs as Prettier does", async () => { + const formatted = await format("if (ready) { work(); }", { + ...manifest.prettier, + parser: "typescript", + useTabs: false, + }); + const indentation = /\n( +)work/u.exec(formatted)?.[1] ?? ""; + expect(indentation.length).toBeGreaterThan(0); + + const text = paragraph(width - indentation.length - 3); + expect(await diagnostics(`\t// ${text}`)).toEqual([]); + expect(await diagnostics(`\t// ${text}.`)).toEqual([expect.objectContaining({ messageId: "maxComment" })]); +}); + +test("allows a line holding a long URL", async () => { + const source = `// Read https://example.com/${"segment/".repeat(width)} for details.`; + expect(await diagnostics(source)).toEqual([]); +}); + +test.each([ + { prefix: "// eslint-disable-next-line", suffix: "" }, + { prefix: "// eslint-disable-line", suffix: "" }, + { prefix: "/* eslint-disable", suffix: " */" }, + { prefix: "/* eslint-disable-next-line", suffix: " */" }, + { prefix: "/* eslint-disable-line", suffix: " */" }, +])("allows a long reason on $prefix", async ({ prefix, suffix }) => { + const source = `${prefix} no-debugger -- ${paragraph(width)}${suffix}\ndebugger;`; + expect(await diagnostics(source)).toEqual([]); +}); + +test.each([ + "// Mention eslint-disable-next-line here.", + "// eslint-disable-next-line-example", + "// eslint-disable", + "/* Mention eslint-disable here.", + "/* eslint-disable-example", + "/** eslint-disable", +])("does not exempt ordinary prose starting with %s", async (prefix) => { + const suffix = prefix.startsWith("/*") ? " */" : ""; + expect(await diagnostics(`${prefix} ${paragraph(width)}${suffix}`)).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]); +}); + +test.each(["", " // A trailing comment.", " /* A trailing comment. */"])( + "leaves long code lines to Prettier with suffix %j", + async (suffix) => { + const source = `const value = ${"1 + ".repeat(width)}1;${suffix}`; + expect(await diagnostics(source)).toEqual([]); + }, +); From 82e631152f4b12e3cb615b71a7cdd107a1ae2fc5 Mon Sep 17 00:00:00 2001 From: Lloyd Evans Date: Fri, 9 Oct 2026 00:17:58 +0100 Subject: [PATCH 2/2] Refine comment width exemptions --- eslint.config.mts | 10 +++- scripts/lint/comment-width.test.mts | 74 ++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/eslint.config.mts b/eslint.config.mts index 198dc21..785f295 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -1,5 +1,6 @@ import { defineConfig } from "eslint/config"; import eslint from "@eslint/js"; +import stylistic from "@stylistic/eslint-plugin"; import tseslint from "typescript-eslint"; import globals from "globals"; import type { Options } from "prettier"; @@ -17,6 +18,7 @@ export default defineConfig( tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked, { + plugins: { "@stylistic": stylistic }, rules: { "@stylistic/max-len": [ "error", @@ -27,8 +29,12 @@ export default defineConfig( // Use Prettier's default when the project does not set a tab width. tabWidth: prettierOptions.tabWidth ?? 2, ignoreUrls: true, - ignorePattern: - "^\\s*(?://\\s*eslint-disable-(?:next-line|line)|/\\*\\s*eslint-disable(?:-next-line|-line)?)(?:\\s|\\*/|$)", + ignorePattern: [ + String.raw`^\s*(?://\s*eslint-disable-(?:next-line|line)|(?:\{\s*)?/\*\s*eslint-disable(?:-next-line|-line)?)(?:\s|\*/|$)`, + // TypeScript takes everything after this directive's name as its description. + String.raw`^\s*(?:///?|/\*)\s*@ts-expect-error`, + String.raw`^\s*(?://+|(?:\{\s*)?/\*+|\*)?\s*\S+\s*(?:\*/\s*\}?)?\s*$`, + ].join("|"), }, ], "@typescript-eslint/member-ordering": "error", diff --git a/scripts/lint/comment-width.test.mts b/scripts/lint/comment-width.test.mts index 2bad165..c869136 100644 --- a/scripts/lint/comment-width.test.mts +++ b/scripts/lint/comment-width.test.mts @@ -11,8 +11,8 @@ function paragraph(length: number) { return `${"word ".repeat(length).slice(0, length - 1)}.`; } -async function diagnostics(source: string) { - const results = await eslint.lintText(source, { filePath: "scripts/lint/comment-width-example.mts" }); +async function diagnostics(source: string, filePath = "scripts/lint/comment-width-example.mts", linter = eslint) { + const results = await linter.lintText(source, { filePath }); expect(results).toHaveLength(1); expect(results[0]?.fatalErrorCount).toBe(0); @@ -56,6 +56,76 @@ test.each([ ])("allows a long reason on $prefix", async ({ prefix, suffix }) => { const source = `${prefix} no-debugger -- ${paragraph(width)}${suffix}\ndebugger;`; expect(await diagnostics(source)).toEqual([]); + expect(await diagnostics(`\t${source}`)).toEqual([]); + expect( + await diagnostics(`${prefix.replace("eslint-", "Mention eslint-")} no-debugger -- ${paragraph(width)}${suffix}`), + ).toEqual([expect.objectContaining({ messageId: "maxComment" })]); +}); + +test.each(["", " "])("allows JSX disable comments with spacing %j", async (spacing) => { + const wrap = (comment: string) => `const element = <>\n\t{${spacing}/* ${comment} */}\n;`; + const directive = `eslint-disable-next-line no-debugger -- ${paragraph(width)}`; + + expect(await diagnostics(wrap(directive), "src/example.tsx")).toEqual([]); + expect(await diagnostics(wrap(`Mention ${directive}`), "src/example.tsx")).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]); +}); + +test.each([ + { prefix: "//", suffix: "" }, + { prefix: "///", suffix: "" }, + { prefix: "/*", suffix: " */" }, +])("allows a described type-error directive in $prefix", async ({ prefix, suffix }) => { + for (const separator of [" -- ", ": ", "-example "]) { + const directive = `@ts-expect-error${separator}${paragraph(width)}`; + + expect(await diagnostics(`${prefix} ${directive}${suffix}`)).toEqual([]); + expect(await diagnostics(`${prefix} Mention ${directive}${suffix}`)).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]); + } +}); + +test.each(["@ts-ignore", "@ts-nocheck", "@ts-check"])("does not exempt %s", async (directive) => + expect(await diagnostics(`// ${directive} -- ${paragraph(width)}`)).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]), +); + +test.each(["eslint-disable-next-line no-debugger", "@ts-expect-error"])( + "does not mistake %s inside JSDoc prose for a directive", + async (directive) => + expect(await diagnostics(`/**\n * // ${directive} -- ${paragraph(width)}\n */`)).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]), +); + +test("registers the width rule for files outside the documentation globs", async () => { + const linter = new ESLint({ + overrideConfig: { ...tseslint.configs.disableTypeChecked, files: ["**/*.jsx"] }, + }); + const text = paragraph(width - 3); + + expect(await diagnostics(`// ${text}`, "scripts/lint/example.jsx", linter)).toEqual([]); + expect(await diagnostics(`// ${text}.`, "scripts/lint/example.jsx", linter)).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]); +}); + +test.each([ + { prefix: "// ", suffix: "", path: "scripts/lint/example.mts" }, + { prefix: "/* ", suffix: " */", path: "scripts/lint/example.mts" }, + { prefix: "/**\n * ", suffix: "\n */", path: "scripts/lint/example.mts" }, + { prefix: "/*\n", suffix: "\n*/", path: "scripts/lint/example.mts" }, + { prefix: "const element = <>\n\t{/* ", suffix: " */}\n;", path: "src/example.tsx" }, +])("preserves a single unbroken token after $prefix", async ({ prefix, suffix, path }) => { + const token = `packages/${"segment/".repeat(width)}/index.ts`; + + expect(await diagnostics(`${prefix}${token}${suffix}`, path)).toEqual([]); + expect(await diagnostics(`${prefix}${token} description${suffix}`, path)).toEqual([ + expect.objectContaining({ messageId: "maxComment" }), + ]); }); test.each([