Repository: JoshuaKGoldberg/create-typescript-app (ts)

Issue:
🐛 Bug: ESLint config groups should be deduplicated by comment
### Bug Report Checklist

- [x] I have tried restarting my IDE and the issue persists.
- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue.

### Expected

If two `rules` objects from `blockESLint`'s Addons have the same `comment`, they should be merged together. This will allow end-users to add to the default `// Stylistic concerns that don't interfere with Prettier` group.

### Actual

https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/pull/993/files#r2027963553 shows that no such merging is done now.

### Additional Info

🎁 

Pull request:
fix: deduplicate blockESLint rule groups by comment
## PR Checklist

- [x] Addresses an existing open issue: fixes #2133
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

🎁 

Patch:
diff --git a/src/blocks/blockESLint.ts b/src/blocks/blockESLint.ts
index 0273101..31e2e79 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<typeof zExtensionRuleGroup>;
+
 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<typeof zExtensionRules>;
+
 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<typeof zExtension>;
+
 const zPackageImport = z.object({
 	source: z.union([
 		z.string(),
@@ -292,7 +298,29 @@ export default tseslint.config(
 	},
 });
 
-function printExtension(extension: z.infer<typeof zExtension>) {
+function groupByComment(rulesGroups: ExtensionRuleGroup[]) {
+	const byComment = new Map<string | undefined, ExtensionRuleGroup>();
+	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<typeof zExtension>) {
 		.join(" ");
 }
 
-function printExtensionRules(rules: z.infer<typeof zExtensionRules>) {
+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)},`,


Test patch:
diff --git a/src/blocks/blockESLint.test.ts b/src/blocks/blockESLint.test.ts
index 4bcf229..fd76579 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": [