diff --git a/src/blocks/blockESLint.test.ts b/src/blocks/blockESLint.test.ts index 4bcf229b3..fd76579d1 100644 --- a/src/blocks/blockESLint.test.ts +++ b/src/blocks/blockESLint.test.ts @@ -468,10 +468,14 @@ describe("blockESLint", () => { specifier: "c", }, ], - rules: { - "a/b": "error", - "a/c": ["error", { d: "e" }], - }, + rules: [ + { + entries: { + "a/b": "error", + "a/c": ["error", { d: "e" }], + }, + }, + ], settings: { react: { version: "detect", @@ -596,7 +600,149 @@ describe("blockESLint", () => { { ignores: ["generated", "lib", "node_modules", "pnpm-lock.yaml"] }, { linterOptions: {"reportUnusedDisableDirectives":"error"} }, eslint.configs.recommended, - a.configs.recommended,{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, },{ extends: [c.configs.recommended], rules: {"c/d":"error","c/e":["error",{"f":"g"}]}, },{ extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]},"tsconfigRootDir":import.meta.dirname}}, rules: {"a/b":"error","a/c":["error",{"d":"e"}]}, settings: {"react":{"version":"detect"}}, } + a.configs.recommended,{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, },{ extends: [c.configs.recommended], rules: {"c/d":"error","c/e":["error",{"f":"g"}]}, },{ extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]},"tsconfigRootDir":import.meta.dirname}}, rules: {"a/b": "error","a/c": ["error",{"d":"e"}],}, settings: {"react":{"version":"detect"}}, } + );", + }, + "scripts": [ + { + "commands": [ + "pnpm lint --fix", + ], + "phase": 3, + }, + ], + } + `); + }); + + test("with identical addon rules comments", () => { + const creation = testBlock(blockESLint, { + addons: { + rules: [ + { + comment: "Duplicated comment", + entries: { a: "error" }, + }, + { + comment: "Standalone comment", + entries: { b: "error" }, + }, + { + comment: "Duplicated comment", + entries: { c: "error" }, + }, + ], + }, + options: optionsBase, + }); + + expect(creation).toMatchInlineSnapshot(` + { + "addons": [ + { + "addons": { + "sections": { + "Linting": { + "contents": { + "after": [ + " + For example, ESLint can be run with \`--fix\` to auto-fix some lint rule complaints: + + \`\`\`shell + pnpm run lint --fix + \`\`\` + ", + ], + "before": " + This package includes several forms of linting to enforce consistent code quality and styling. + Each should be shown in VS Code, and can be run manually on the command-line: + ", + "items": [ + "- \`pnpm lint\` ([ESLint](https://eslint.org) with [typescript-eslint](https://typescript-eslint.io)): Lints JavaScript and TypeScript source files", + ], + "plural": "Read the individual documentation for each linter to understand how it can be configured and used best.", + }, + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "jobs": [ + { + "name": "Lint", + "steps": [ + { + "run": "pnpm lint", + }, + ], + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "properties": { + "devDependencies": { + "@eslint/js": "9.22.0", + "@types/node": "22.13.10", + "eslint": "9.22.0", + "typescript-eslint": "8.26.1", + }, + "scripts": { + "lint": "eslint . --max-warnings 0", + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "extensions": [ + "dbaeumer.vscode-eslint", + ], + "settings": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + }, + "eslint.probe": [ + "javascript", + "javascriptreact", + "json", + "jsonc", + "markdown", + "typescript", + "typescriptreact", + "yaml", + ], + "eslint.rules.customizations": [ + { + "rule": "*", + "severity": "warn", + }, + ], + }, + }, + "block": [Function], + }, + ], + "files": { + "eslint.config.js": "import eslint from "@eslint/js"; + import tseslint from "typescript-eslint"; + + export default tseslint.config( + { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, + { linterOptions: {"reportUnusedDisableDirectives":"error"} }, + eslint.configs.recommended, + { extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]},"tsconfigRootDir":import.meta.dirname}}, rules: { + + // Duplicated comment + "a": "error","c": "error", + + // Standalone comment + "b": "error",}, } );", }, "scripts": [ diff --git a/src/blocks/blockESLint.ts b/src/blocks/blockESLint.ts index 02731010a..31e2e7922 100644 --- a/src/blocks/blockESLint.ts +++ b/src/blocks/blockESLint.ts @@ -28,16 +28,20 @@ const zRuleOptions = z.union([ ]), ]); +const zExtensionRuleGroup = z.object({ + comment: z.string().optional(), + entries: z.record(z.string(), zRuleOptions), +}); + +type ExtensionRuleGroup = z.infer; + const zExtensionRules = z.union([ z.record(z.string(), zRuleOptions), - z.array( - z.object({ - comment: z.string().optional(), - entries: z.record(z.string(), zRuleOptions), - }), - ), + z.array(zExtensionRuleGroup), ]); +type ExtensionRules = z.infer; + const zExtension = z.object({ extends: z.array(z.string()).optional(), files: z.array(z.string()).optional(), @@ -48,6 +52,8 @@ const zExtension = z.object({ settings: z.record(z.string(), z.unknown()).optional(), }); +type Extension = z.infer; + const zPackageImport = z.object({ source: z.union([ z.string(), @@ -292,7 +298,29 @@ export default tseslint.config( }, }); -function printExtension(extension: z.infer) { +function groupByComment(rulesGroups: ExtensionRuleGroup[]) { + const byComment = new Map(); + const grouped: typeof rulesGroups = []; + + for (const group of rulesGroups) { + const existing = byComment.get(group.comment); + + if (existing) { + existing.entries = { + ...existing.entries, + ...group.entries, + }; + continue; + } else { + byComment.set(group.comment, group); + grouped.push(group); + } + } + + return grouped; +} + +function printExtension(extension: Extension) { return [ "{", extension.extends && `extends: [${extension.extends.join(", ")}],`, @@ -311,14 +339,14 @@ function printExtension(extension: z.infer) { .join(" "); } -function printExtensionRules(rules: z.infer) { +function printExtensionRules(rules: ExtensionRules) { if (!Array.isArray(rules)) { return JSON.stringify(rules); } return [ "{", - ...rules.flatMap((group) => [ + ...groupByComment(rules).flatMap((group) => [ printGroupComment(group.comment), ...Object.entries(group.entries).map( ([ruleName, options]) => `"${ruleName}": ${JSON.stringify(options)},`,