Skip to content

Commit 2a11884

Browse files
fix: deduplicate blockESLint rule groups by comment (#2139)
## 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 🎁
1 parent e7ff661 commit 2a11884

File tree

2 files changed

+188
-14
lines changed

2 files changed

+188
-14
lines changed

src/blocks/blockESLint.test.ts

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,14 @@ describe("blockESLint", () => {
468468
specifier: "c",
469469
},
470470
],
471-
rules: {
472-
"a/b": "error",
473-
"a/c": ["error", { d: "e" }],
474-
},
471+
rules: [
472+
{
473+
entries: {
474+
"a/b": "error",
475+
"a/c": ["error", { d: "e" }],
476+
},
477+
},
478+
],
475479
settings: {
476480
react: {
477481
version: "detect",
@@ -596,7 +600,149 @@ describe("blockESLint", () => {
596600
{ ignores: ["generated", "lib", "node_modules", "pnpm-lock.yaml"] },
597601
{ linterOptions: {"reportUnusedDisableDirectives":"error"} },
598602
eslint.configs.recommended,
599-
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"}}, }
603+
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"}}, }
604+
);",
605+
},
606+
"scripts": [
607+
{
608+
"commands": [
609+
"pnpm lint --fix",
610+
],
611+
"phase": 3,
612+
},
613+
],
614+
}
615+
`);
616+
});
617+
618+
test("with identical addon rules comments", () => {
619+
const creation = testBlock(blockESLint, {
620+
addons: {
621+
rules: [
622+
{
623+
comment: "Duplicated comment",
624+
entries: { a: "error" },
625+
},
626+
{
627+
comment: "Standalone comment",
628+
entries: { b: "error" },
629+
},
630+
{
631+
comment: "Duplicated comment",
632+
entries: { c: "error" },
633+
},
634+
],
635+
},
636+
options: optionsBase,
637+
});
638+
639+
expect(creation).toMatchInlineSnapshot(`
640+
{
641+
"addons": [
642+
{
643+
"addons": {
644+
"sections": {
645+
"Linting": {
646+
"contents": {
647+
"after": [
648+
"
649+
For example, ESLint can be run with \`--fix\` to auto-fix some lint rule complaints:
650+
651+
\`\`\`shell
652+
pnpm run lint --fix
653+
\`\`\`
654+
",
655+
],
656+
"before": "
657+
This package includes several forms of linting to enforce consistent code quality and styling.
658+
Each should be shown in VS Code, and can be run manually on the command-line:
659+
",
660+
"items": [
661+
"- \`pnpm lint\` ([ESLint](https://eslint.org) with [typescript-eslint](https://typescript-eslint.io)): Lints JavaScript and TypeScript source files",
662+
],
663+
"plural": "Read the individual documentation for each linter to understand how it can be configured and used best.",
664+
},
665+
},
666+
},
667+
},
668+
"block": [Function],
669+
},
670+
{
671+
"addons": {
672+
"jobs": [
673+
{
674+
"name": "Lint",
675+
"steps": [
676+
{
677+
"run": "pnpm lint",
678+
},
679+
],
680+
},
681+
],
682+
},
683+
"block": [Function],
684+
},
685+
{
686+
"addons": {
687+
"properties": {
688+
"devDependencies": {
689+
"@eslint/js": "9.22.0",
690+
"@types/node": "22.13.10",
691+
"eslint": "9.22.0",
692+
"typescript-eslint": "8.26.1",
693+
},
694+
"scripts": {
695+
"lint": "eslint . --max-warnings 0",
696+
},
697+
},
698+
},
699+
"block": [Function],
700+
},
701+
{
702+
"addons": {
703+
"extensions": [
704+
"dbaeumer.vscode-eslint",
705+
],
706+
"settings": {
707+
"editor.codeActionsOnSave": {
708+
"source.fixAll.eslint": "explicit",
709+
},
710+
"eslint.probe": [
711+
"javascript",
712+
"javascriptreact",
713+
"json",
714+
"jsonc",
715+
"markdown",
716+
"typescript",
717+
"typescriptreact",
718+
"yaml",
719+
],
720+
"eslint.rules.customizations": [
721+
{
722+
"rule": "*",
723+
"severity": "warn",
724+
},
725+
],
726+
},
727+
},
728+
"block": [Function],
729+
},
730+
],
731+
"files": {
732+
"eslint.config.js": "import eslint from "@eslint/js";
733+
import tseslint from "typescript-eslint";
734+
735+
export default tseslint.config(
736+
{ ignores: ["lib", "node_modules", "pnpm-lock.yaml"] },
737+
{ linterOptions: {"reportUnusedDisableDirectives":"error"} },
738+
eslint.configs.recommended,
739+
{ extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]},"tsconfigRootDir":import.meta.dirname}}, rules: {
740+
741+
// Duplicated comment
742+
"a": "error","c": "error",
743+
744+
// Standalone comment
745+
"b": "error",}, }
600746
);",
601747
},
602748
"scripts": [

src/blocks/blockESLint.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,20 @@ const zRuleOptions = z.union([
2828
]),
2929
]);
3030

31+
const zExtensionRuleGroup = z.object({
32+
comment: z.string().optional(),
33+
entries: z.record(z.string(), zRuleOptions),
34+
});
35+
36+
type ExtensionRuleGroup = z.infer<typeof zExtensionRuleGroup>;
37+
3138
const zExtensionRules = z.union([
3239
z.record(z.string(), zRuleOptions),
33-
z.array(
34-
z.object({
35-
comment: z.string().optional(),
36-
entries: z.record(z.string(), zRuleOptions),
37-
}),
38-
),
40+
z.array(zExtensionRuleGroup),
3941
]);
4042

43+
type ExtensionRules = z.infer<typeof zExtensionRules>;
44+
4145
const zExtension = z.object({
4246
extends: z.array(z.string()).optional(),
4347
files: z.array(z.string()).optional(),
@@ -48,6 +52,8 @@ const zExtension = z.object({
4852
settings: z.record(z.string(), z.unknown()).optional(),
4953
});
5054

55+
type Extension = z.infer<typeof zExtension>;
56+
5157
const zPackageImport = z.object({
5258
source: z.union([
5359
z.string(),
@@ -292,7 +298,29 @@ export default tseslint.config(
292298
},
293299
});
294300

295-
function printExtension(extension: z.infer<typeof zExtension>) {
301+
function groupByComment(rulesGroups: ExtensionRuleGroup[]) {
302+
const byComment = new Map<string | undefined, ExtensionRuleGroup>();
303+
const grouped: typeof rulesGroups = [];
304+
305+
for (const group of rulesGroups) {
306+
const existing = byComment.get(group.comment);
307+
308+
if (existing) {
309+
existing.entries = {
310+
...existing.entries,
311+
...group.entries,
312+
};
313+
continue;
314+
} else {
315+
byComment.set(group.comment, group);
316+
grouped.push(group);
317+
}
318+
}
319+
320+
return grouped;
321+
}
322+
323+
function printExtension(extension: Extension) {
296324
return [
297325
"{",
298326
extension.extends && `extends: [${extension.extends.join(", ")}],`,
@@ -311,14 +339,14 @@ function printExtension(extension: z.infer<typeof zExtension>) {
311339
.join(" ");
312340
}
313341

314-
function printExtensionRules(rules: z.infer<typeof zExtensionRules>) {
342+
function printExtensionRules(rules: ExtensionRules) {
315343
if (!Array.isArray(rules)) {
316344
return JSON.stringify(rules);
317345
}
318346

319347
return [
320348
"{",
321-
...rules.flatMap((group) => [
349+
...groupByComment(rules).flatMap((group) => [
322350
printGroupComment(group.comment),
323351
...Object.entries(group.entries).map(
324352
([ruleName, options]) => `"${ruleName}": ${JSON.stringify(options)},`,

0 commit comments

Comments
 (0)