From 8e0bfff8c4977715342310b79a4b6f9536107638 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 2 Dec 2025 15:54:44 -0500 Subject: [PATCH 1/4] feat: move all ESLint configuration into files-based blocks --- eslint.config.js | 31 +- src/blocks/blockESLint.test.ts | 66 +- src/blocks/blockESLint.ts | 106 +- src/blocks/blockESLintComments.ts | 7 +- src/blocks/blockESLintJSDoc.ts | 11 +- src/blocks/blockESLintJSONC.ts | 7 +- src/blocks/blockESLintMarkdown.ts | 7 +- src/blocks/blockESLintNode.test.ts | 9 +- src/blocks/blockESLintNode.ts | 5 +- src/blocks/blockESLintPackageJson.test.ts | 18 +- src/blocks/blockESLintPackageJson.ts | 7 +- src/blocks/blockESLintPerfectionist.ts | 19 +- src/blocks/blockESLintPlugin.test.ts | 1276 +++++++++++---------- src/blocks/blockESLintPlugin.ts | 7 +- src/blocks/blockESLintRegexp.ts | 7 +- src/blocks/blockVitest.test.ts | 40 +- src/blocks/blockVitest.ts | 6 +- src/blocks/eslint/schemas.ts | 2 +- 18 files changed, 882 insertions(+), 749 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index de2c28131..c91d73f02 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,19 +27,16 @@ export default defineConfig( ignores: ["**/*.snap", "coverage", "lib", "node_modules", "pnpm-lock.yaml"], }, { linterOptions: { reportUnusedDisableDirectives: "error" } }, - eslint.configs.recommended, - comments.recommended, - jsdoc.configs["flat/contents-typescript-error"], - jsdoc.configs["flat/logical-typescript-error"], - jsdoc.configs["flat/stylistic-typescript-error"], - jsonc.configs["flat/recommended-with-json"], - markdown.configs.recommended, - n.configs["flat/recommended"], - packageJson.configs.recommended, - perfectionist.configs["recommended-natural"], - regexp.configs["flat/recommended"], { extends: [ + comments.recommended, + eslint.configs.recommended, + jsdoc.configs["flat/contents-typescript-error"], + jsdoc.configs["flat/logical-typescript-error"], + jsdoc.configs["flat/stylistic-typescript-error"], + n.configs["flat/recommended"], + perfectionist.configs["recommended-natural"], + regexp.configs["flat/recommended"], tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked, ], @@ -76,11 +73,13 @@ export default defineConfig( "object-shorthand": "error", "operator-assignment": "error", }, - settings: { - perfectionist: { partitionByComment: true, type: "natural" }, - vitest: { typecheck: true }, - }, + settings: { perfectionist: { partitionByComment: true, type: "natural" } }, + }, + { + extends: [jsonc.configs["flat/recommended-with-json"]], + files: ["**/*.json"], }, + { extends: [markdown.configs.recommended], files: ["**/*.md"] }, { extends: [tseslint.configs.disableTypeChecked], files: ["**/*.md/*.ts"], @@ -90,6 +89,7 @@ export default defineConfig( extends: [vitest.configs.recommended], files: ["**/*.test.*"], rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, + settings: { vitest: { typecheck: true } }, }, { extends: [yml.configs["flat/standard"], yml.configs["flat/prettier"]], @@ -106,4 +106,5 @@ export default defineConfig( ], }, }, + { extends: [packageJson.configs.recommended], files: ["package.json"] }, ); diff --git a/src/blocks/blockESLint.test.ts b/src/blocks/blockESLint.test.ts index bd7246c12..6aa9c670a 100644 --- a/src/blocks/blockESLint.test.ts +++ b/src/blocks/blockESLint.test.ts @@ -119,9 +119,8 @@ describe("blockESLint", () => { export default defineConfig( { 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"]}}}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ @@ -274,9 +273,8 @@ describe("blockESLint", () => { export default defineConfig( { 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"]}}}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ @@ -432,9 +430,8 @@ describe("blockESLint", () => { export default defineConfig( { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, - { linterOptions: {"reportUnusedDisableDirectives":"error"} }, - eslint.configs.recommended, - { extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,mjs,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, },{ files: ["*.mjs"], languageOptions: {"sourceType":"module"}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { files: ["*.mjs"], languageOptions: {"sourceType":"module"}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,mjs,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ @@ -455,20 +452,20 @@ describe("blockESLint", () => { beforeLint: "Before lint.", explanations: ["This is a great config!", "You should use it!"], extensions: [ - "a.configs.recommended", { - extends: ["b.configs.recommended"], - files: ["**/*.b"], + extends: ["a.configs.recommended"], + files: ["**/*.a"], rules: { - "b/c": "error", - "b/d": ["error", { e: "f" }], + "a/b": "error", + "a/c": ["error", { d: "e" }], }, }, { - extends: ["c.configs.recommended"], + extends: ["b.configs.recommended"], + files: ["**/*.b"], rules: { - "c/d": "error", - "c/e": ["error", { f: "g" }], + "b/c": "error", + "b/d": ["error", { e: "f" }], }, }, ], @@ -612,9 +609,8 @@ describe("blockESLint", () => { export default defineConfig( { 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"]}}}, rules: {"a/b": "error","a/c": ["error",{"d":"e"}],}, settings: {"react":{"version":"detect"}}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [a.configs.recommended], files: ["**/*.a"], rules: {"a/b":"error","a/c":["error",{"d":"e"}]}, },{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: {"a/b": "error","a/c": ["error",{"d":"e"}],}, } );", }, "scripts": [ @@ -749,9 +745,8 @@ describe("blockESLint", () => { export default defineConfig( { 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"]}}}, rules: { + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: { // Duplicated comment "a": "error","c": "error", @@ -892,9 +887,8 @@ describe("blockESLint", () => { export default defineConfig( { 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"]}}}, rules: { + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: { // One line "a": "error", @@ -1031,9 +1025,8 @@ describe("blockESLint", () => { export default defineConfig( { 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","bin/index.js"]}}}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s","bin/index.js"]}}}, } );", }, "scripts": [ @@ -1158,9 +1151,8 @@ describe("blockESLint", () => { export default defineConfig( { 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","bin/index.js"]}}}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s","bin/index.js"]}}}, } );", }, "scripts": [ @@ -1282,9 +1274,8 @@ describe("blockESLint", () => { export default defineConfig( { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, - { linterOptions: {"reportUnusedDisableDirectives":"error"} }, - eslint.configs.recommended, - { extends: [tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,mjs,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, },{ files: ["*.mjs"], languageOptions: {"sourceType":"module"}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { files: ["*.mjs"], languageOptions: {"sourceType":"module"}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,mjs,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ @@ -1406,9 +1397,8 @@ describe("blockESLint", () => { export default defineConfig( { 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"]}}}, } + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ diff --git a/src/blocks/blockESLint.ts b/src/blocks/blockESLint.ts index 91eecb448..72e9bc41d 100644 --- a/src/blocks/blockESLint.ts +++ b/src/blocks/blockESLint.ts @@ -31,11 +31,10 @@ export const blockESLint = base.createBlock({ addons: { beforeLint: z.string().optional(), explanations: z.array(z.string()).default([]), - extensions: z.array(z.union([z.string(), zExtension])).default([]), + extensions: z.array(zExtension).default([]), ignores: z.array(z.string()).default([]), imports: z.array(zPackageImport).default([]), rules: zExtensionRules.optional(), - settings: z.record(z.string(), z.unknown()).optional(), }, intake({ files }) { const eslintConfigRaw = intakeFile(files, [ @@ -45,8 +44,7 @@ export const blockESLint = base.createBlock({ return eslintConfigRaw ? blockESLintIntake(eslintConfigRaw[0]) : undefined; }, produce({ addons, options }) { - const { explanations, extensions, ignores, imports, rules, settings } = - addons; + const { explanations, extensions, ignores, imports, rules } = addons; const [configFileName, fileExtensions] = options.type === "commonjs" @@ -80,9 +78,10 @@ export const blockESLint = base.createBlock({ ), ).sort(); - const extensionLines = [ - printExtension({ + const extensionEntries = mergeAllExtensions( + { extends: [ + "eslint.configs.recommended", "tseslint.configs.strictTypeChecked", "tseslint.configs.stylisticTypeChecked", ], @@ -106,22 +105,23 @@ export const blockESLint = base.createBlock({ }, }, ...(rules && { rules }), - ...(settings && { settings }), - }), + }, + ...extensions, ...(options.type === "commonjs" ? [ - printExtension({ + { files: ["*.mjs"], languageOptions: { sourceType: "module" }, - }), + }, ] : []), - ...extensions.map((extension) => - typeof extension === "string" ? extension : printExtension(extension), - ), - ] - .sort((a, b) => processForSort(a).localeCompare(processForSort(b))) - .map((t) => t); + ); + + const extensionLines = extensionEntries + .sort((a, b) => + processForSort(a.files).localeCompare(processForSort(b.files)), + ) + .map(printExtension); return { addons: [ @@ -224,10 +224,7 @@ Each should be shown in VS Code, and can be run manually on the command-line: export default defineConfig( { ignores: [${ignoreLines.join(", ")}] }, - ${printExtension({ - linterOptions: { reportUnusedDisableDirectives: "error" }, - })}, - eslint.configs.recommended, + { linterOptions: { reportUnusedDisableDirectives: "error" } }, ${extensionLines.join(",")} );`, }, @@ -292,12 +289,69 @@ function groupByComment(rulesGroups: ExtensionRuleGroup[]) { return grouped; } +function mergeAllExtensions(...extensions: Extension[]) { + const entries: Record = {}; + + for (const extension of extensions) { + const filesKey = JSON.stringify(extension.files); + + entries[filesKey] = + filesKey in entries + ? mergeExtensions(entries[filesKey], extension, extension.files) + : extension; + } + + return Object.values(entries); +} + +function mergeExtensions( + a: Extension, + b: Extension, + files: string[], +): Extension { + return { + extends: Array.from( + new Set([...(a.extends ?? []), ...(b.extends ?? [])]), + ).sort(), + files, + languageOptions: (a.languageOptions ?? b.languageOptions) && { + ...(a.languageOptions ?? {}), + ...(b.languageOptions ?? {}), + }, + linterOptions: (a.linterOptions ?? b.linterOptions) && { + ...(a.linterOptions ?? {}), + ...(b.linterOptions ?? {}), + }, + plugins: (a.plugins ?? b.plugins) && { ...a.plugins, ...b.plugins }, + rules: mergeExtensionsRules(a.rules, b.rules), + settings: (a.settings ?? b.settings) && { ...a.settings, ...b.settings }, + }; +} + +function mergeExtensionsRules( + a: ExtensionRules | undefined, + b: ExtensionRules | undefined, +): ExtensionRules | undefined { + if (!a || !b) { + return a ?? b; + } + + if (Array.isArray(a) && Array.isArray(b)) { + return [...a, ...b]; + } + + if (!Array.isArray(a) && !Array.isArray(b)) { + return { ...a, ...b }; + } + + console.log({ a, b }); +} + function printExtension(extension: Extension) { return [ "{", extension.extends && `extends: [${extension.extends.join(", ")}],`, - extension.files && - `files: [${extension.files.map((glob) => JSON.stringify(glob)).join(", ")}],`, + `files: [${extension.files.map((glob) => JSON.stringify(glob)).join(", ")}],`, extension.languageOptions && `languageOptions: ${JSON.stringify(extension.languageOptions).replace('"import.meta.dirname"', "import.meta.dirname")},`, extension.linterOptions && @@ -332,10 +386,6 @@ function printGroupComment(comment: string | undefined) { return comment ? `\n\n// ${comment.replaceAll("\n", "\n// ")}\n` : ""; } -function processForSort(line: string) { - if (line.startsWith("...") || /\w+/.test(line[0])) { - return `A\n${line.replaceAll(/\W+/g, "")}`; - } - - return `B\n${(/files: (.+)/.exec(line)?.[1] ?? line).replaceAll(/\W+/g, "")}`; +function processForSort(files: string[]) { + return files.join("").replaceAll("{", ""); } diff --git a/src/blocks/blockESLintComments.ts b/src/blocks/blockESLintComments.ts index c8cf6de8a..0c726b4aa 100644 --- a/src/blocks/blockESLintComments.ts +++ b/src/blocks/blockESLintComments.ts @@ -9,7 +9,12 @@ export const blockESLintComments = base.createBlock({ return { addons: [ blockESLint({ - extensions: ["comments.recommended"], + extensions: [ + { + extends: ["comments.recommended"], + files: ["**/*.{js,ts}"], + }, + ], imports: [ { source: "@eslint-community/eslint-plugin-eslint-comments/configs", diff --git a/src/blocks/blockESLintJSDoc.ts b/src/blocks/blockESLintJSDoc.ts index a91a6b3b5..0dc935a3a 100644 --- a/src/blocks/blockESLintJSDoc.ts +++ b/src/blocks/blockESLintJSDoc.ts @@ -10,9 +10,14 @@ export const blockESLintJSDoc = base.createBlock({ addons: [ blockESLint({ extensions: [ - 'jsdoc.configs["flat/contents-typescript-error"]', - 'jsdoc.configs["flat/logical-typescript-error"]', - 'jsdoc.configs["flat/stylistic-typescript-error"]', + { + extends: [ + 'jsdoc.configs["flat/contents-typescript-error"]', + 'jsdoc.configs["flat/logical-typescript-error"]', + 'jsdoc.configs["flat/stylistic-typescript-error"]', + ], + files: ["**/*.{js,ts}"], + }, ], imports: [{ source: "eslint-plugin-jsdoc", specifier: "jsdoc" }], }), diff --git a/src/blocks/blockESLintJSONC.ts b/src/blocks/blockESLintJSONC.ts index 471294cab..1c856ccd2 100644 --- a/src/blocks/blockESLintJSONC.ts +++ b/src/blocks/blockESLintJSONC.ts @@ -9,7 +9,12 @@ export const blockESLintJSONC = base.createBlock({ return { addons: [ blockESLint({ - extensions: [`jsonc.configs["flat/recommended-with-json"]`], + extensions: [ + { + extends: [`jsonc.configs["flat/recommended-with-json"]`], + files: ["**/*.json"], + }, + ], imports: [{ source: "eslint-plugin-jsonc", specifier: "jsonc" }], }), ], diff --git a/src/blocks/blockESLintMarkdown.ts b/src/blocks/blockESLintMarkdown.ts index 976f03810..d109793d3 100644 --- a/src/blocks/blockESLintMarkdown.ts +++ b/src/blocks/blockESLintMarkdown.ts @@ -9,7 +9,12 @@ export const blockESLintMarkdown = base.createBlock({ return { addons: [ blockESLint({ - extensions: ["markdown.configs.recommended"], + extensions: [ + { + extends: ["markdown.configs.recommended"], + files: ["**/*.md"], + }, + ], imports: [ { source: "eslint-plugin-markdown", diff --git a/src/blocks/blockESLintNode.test.ts b/src/blocks/blockESLintNode.test.ts index 841084249..04c7e0d8a 100644 --- a/src/blocks/blockESLintNode.test.ts +++ b/src/blocks/blockESLintNode.test.ts @@ -20,7 +20,14 @@ describe("blockESLintNode", () => { { "addons": { "extensions": [ - "n.configs["flat/recommended"]", + { + "extends": [ + "n.configs["flat/recommended"]", + ], + "files": [ + "**/*.{js,ts}", + ], + }, { "extends": [ "tseslint.configs.disableTypeChecked", diff --git a/src/blocks/blockESLintNode.ts b/src/blocks/blockESLintNode.ts index d0a4c72fc..786c0f6f2 100644 --- a/src/blocks/blockESLintNode.ts +++ b/src/blocks/blockESLintNode.ts @@ -10,7 +10,10 @@ export const blockESLintNode = base.createBlock({ addons: [ blockESLint({ extensions: [ - 'n.configs["flat/recommended"]', + { + extends: ['n.configs["flat/recommended"]'], + files: ["**/*.{js,ts}"], + }, { extends: ["tseslint.configs.disableTypeChecked"], files: ["**/*.md/*.ts"], diff --git a/src/blocks/blockESLintPackageJson.test.ts b/src/blocks/blockESLintPackageJson.test.ts index 541788ae7..5250ed1ed 100644 --- a/src/blocks/blockESLintPackageJson.test.ts +++ b/src/blocks/blockESLintPackageJson.test.ts @@ -16,7 +16,14 @@ describe("blockESLintPackageJson", () => { { "addons": { "extensions": [ - "packageJson.configs.recommended", + { + "extends": [ + "packageJson.configs.recommended", + ], + "files": [ + "package.json", + ], + }, ], "imports": [ { @@ -54,7 +61,14 @@ describe("blockESLintPackageJson", () => { { "addons": { "extensions": [ - "packageJson.configs.recommended", + { + "extends": [ + "packageJson.configs.recommended", + ], + "files": [ + "package.json", + ], + }, ], "imports": [ { diff --git a/src/blocks/blockESLintPackageJson.ts b/src/blocks/blockESLintPackageJson.ts index c21426067..2f357cec0 100644 --- a/src/blocks/blockESLintPackageJson.ts +++ b/src/blocks/blockESLintPackageJson.ts @@ -13,7 +13,12 @@ export const blockESLintPackageJson = base.createBlock({ return { addons: [ blockESLint({ - extensions: ["packageJson.configs.recommended"], + extensions: [ + { + extends: ["packageJson.configs.recommended"], + files: ["package.json"], + }, + ], imports: [ { source: "eslint-plugin-package-json", diff --git a/src/blocks/blockESLintPerfectionist.ts b/src/blocks/blockESLintPerfectionist.ts index ef2527e72..3e53de595 100644 --- a/src/blocks/blockESLintPerfectionist.ts +++ b/src/blocks/blockESLintPerfectionist.ts @@ -9,19 +9,24 @@ export const blockESLintPerfectionist = base.createBlock({ return { addons: [ blockESLint({ - extensions: [`perfectionist.configs["recommended-natural"]`], + extensions: [ + { + extends: [`perfectionist.configs["recommended-natural"]`], + files: ["**/*.{js,ts}"], + settings: { + perfectionist: { + partitionByComment: true, + type: "natural", + }, + }, + }, + ], imports: [ { source: "eslint-plugin-perfectionist", specifier: "perfectionist", }, ], - settings: { - perfectionist: { - partitionByComment: true, - type: "natural", - }, - }, }), ], }; diff --git a/src/blocks/blockESLintPlugin.test.ts b/src/blocks/blockESLintPlugin.test.ts index 8e31dae88..61b13e886 100644 --- a/src/blocks/blockESLintPlugin.test.ts +++ b/src/blocks/blockESLintPlugin.test.ts @@ -52,7 +52,14 @@ describe("blockESLintPlugin", () => { { "addons": { "extensions": [ - "eslintPlugin.configs["flat/recommended"]", + { + "extends": [ + "eslintPlugin.configs["flat/recommended"]", + ], + "files": [ + "**/*.{js,ts}", + ], + }, ], "ignores": [ ".eslint-doc-generatorrc.js", @@ -189,639 +196,660 @@ describe("blockESLintPlugin", () => { }); expect(creation).toMatchInlineSnapshot(` - { - "addons": [ - { - "addons": { - "words": [ - "eslint-doc-generatorrc", - ], - }, - "block": [Function], - }, - { - "addons": { - "sections": { - "Building": { - "innerSections": [ - { - "contents": " - Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. - - \`\`\`shell - pnpm build:docs - \`\`\` - ", - "heading": "Building Docs", - }, - ], - }, - "Linting": { - "contents": { - "items": [ - "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", - ], - }, - }, - }, - }, - "block": [Function], - }, - { - "addons": { - "extensions": [ - "eslintPlugin.configs["flat/recommended"]", - ], - "ignores": [ - ".eslint-doc-generatorrc.mjs", - "docs/rules/*/*.ts", - ], - "imports": [ - { - "source": { - "packageName": "eslint-plugin-eslint-plugin", - "version": "6.4.0", - }, - "specifier": "eslintPlugin", - }, - ], - }, - "block": [Function], - }, - { - "addons": { - "jobs": [ - { - "name": "Lint Docs", - "steps": [ - { - "run": "pnpm build || exit 0", - }, - { - "run": "pnpm lint:docs", - }, - ], - }, - ], - }, - "block": [Function], - }, - { - "addons": { - "defaultUsage": [ - "Add this plugin to the list of plugins in your [ESLint configuration file](https://eslint.org/docs/latest/use/configure/configuration-files): - - \`\`\`shell - npm i test-repository -D - \`\`\` - - \`\`\`ts - import testRepository from "test-repository"; - - export default [ - // (other plugins) - testRepository.configs.recommended, // 👈 - ]; - \`\`\` - - ### Rules - - These are all set to \`"error"\` in the recommended config: - - ", - ], - }, - "block": [Function], - }, - { - "addons": { - "properties": { - "dependencies": { - "@typescript-eslint/utils": "^8.29.0", - }, - "devDependencies": { - "@typescript-eslint/rule-tester": "8.29.1", - "eslint-doc-generator": "2.1.0", - "eslint-plugin-eslint-plugin": "6.4.0", - }, - "scripts": { - "build:docs": "pnpm build --no-dts && eslint-doc-generator", - "lint:docs": "eslint-doc-generator --check", - }, - }, - }, - "block": [Function], - }, - { - "addons": { - "coverage": { - "exclude": [ - "src/index.ts", - "src/rules/index.ts", - ], - }, - }, - "block": [Function], - }, - ], - "files": { - ".eslint-doc-generatorrc.mjs": "import prettier from "prettier"; - - /** @type {import('eslint-doc-generator').GenerateOptions} */ - const config = { - postprocess: async (content, path) => - prettier.format(content, { - ...(await prettier.resolveConfig(path)), - parser: "markdown", - }), - ruleDocTitleFormat: "name", - }; - - export default config; - ", - }, - "scripts": [ - { - "commands": [ - "pnpm build", - ], - "phase": 2, - }, - { - "commands": [ - "pnpm eslint-doc-generator --init-rule-docs", - ], - "phase": 3, - }, - ], - } - `); - }); + { + "addons": [ + { + "addons": { + "words": [ + "eslint-doc-generatorrc", + ], + }, + "block": [Function], + }, + { + "addons": { + "sections": { + "Building": { + "innerSections": [ + { + "contents": " + Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. - test("setup mode", () => { - const creation = testBlock(blockESLintPlugin, { - mode: "setup", - options: optionsBase, - }); + \`\`\`shell + pnpm build:docs + \`\`\` + ", + "heading": "Building Docs", + }, + ], + }, + "Linting": { + "contents": { + "items": [ + "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", + ], + }, + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "extensions": [ + { + "extends": [ + "eslintPlugin.configs["flat/recommended"]", + ], + "files": [ + "**/*.{js,ts}", + ], + }, + ], + "ignores": [ + ".eslint-doc-generatorrc.mjs", + "docs/rules/*/*.ts", + ], + "imports": [ + { + "source": { + "packageName": "eslint-plugin-eslint-plugin", + "version": "6.4.0", + }, + "specifier": "eslintPlugin", + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "jobs": [ + { + "name": "Lint Docs", + "steps": [ + { + "run": "pnpm build || exit 0", + }, + { + "run": "pnpm lint:docs", + }, + ], + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "defaultUsage": [ + "Add this plugin to the list of plugins in your [ESLint configuration file](https://eslint.org/docs/latest/use/configure/configuration-files): - expect(creation).toMatchInlineSnapshot(` - { - "addons": [ - { - "addons": { - "words": [ - "eslint-doc-generatorrc", - ], - }, - "block": [Function], - }, - { - "addons": { - "sections": { - "Building": { - "innerSections": [ - { - "contents": " - Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. - - \`\`\`shell - pnpm build:docs - \`\`\` - ", - "heading": "Building Docs", - }, - ], - }, - "Linting": { - "contents": { - "items": [ - "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", - ], - }, - }, - }, - }, - "block": [Function], - }, - { - "addons": { - "extensions": [ - "eslintPlugin.configs["flat/recommended"]", - ], - "ignores": [ - ".eslint-doc-generatorrc.js", - "docs/rules/*/*.ts", - ], - "imports": [ - { - "source": { - "packageName": "eslint-plugin-eslint-plugin", - "version": "6.4.0", - }, - "specifier": "eslintPlugin", - }, - ], - }, - "block": [Function], - }, - { - "addons": { - "jobs": [ - { - "name": "Lint Docs", - "steps": [ - { - "run": "pnpm build || exit 0", - }, - { - "run": "pnpm lint:docs", - }, - ], - }, - ], - }, - "block": [Function], - }, - { - "addons": { - "defaultUsage": [ - "Add this plugin to the list of plugins in your [ESLint configuration file](https://eslint.org/docs/latest/use/configure/configuration-files): - - \`\`\`shell - npm i test-repository -D - \`\`\` - - \`\`\`ts - import testRepository from "test-repository"; - - export default [ - // (other plugins) - testRepository.configs.recommended, // 👈 - ]; - \`\`\` - - ### Rules - - These are all set to \`"error"\` in the recommended config: - - ", - ], - }, - "block": [Function], - }, - { - "addons": { - "properties": { - "dependencies": { - "@typescript-eslint/utils": "^8.29.0", - }, - "devDependencies": { - "@typescript-eslint/rule-tester": "8.29.1", - "eslint-doc-generator": "2.1.0", - "eslint-plugin-eslint-plugin": "6.4.0", - }, - "scripts": { - "build:docs": "pnpm build --no-dts && eslint-doc-generator", - "lint:docs": "eslint-doc-generator --check", - }, - }, - }, - "block": [Function], - }, - { - "addons": { - "coverage": { - "exclude": [ - "src/index.ts", - "src/rules/index.ts", - ], - }, - }, - "block": [Function], - }, - ], - "files": { - ".eslint-doc-generatorrc.js": "import prettier from "prettier"; - - /** @type {import('eslint-doc-generator').GenerateOptions} */ - const config = { - postprocess: async (content, path) => - prettier.format(content, { - ...(await prettier.resolveConfig(path)), - parser: "markdown", - }), - ruleDocTitleFormat: "name", - }; - - export default config; - ", - "src": { - "index.ts": "import Module from "node:module"; - - import { rules } from "./rules/index.js"; - - const require = Module.createRequire(import.meta.url); - - const { name, version } = - // \`import\`ing here would bypass the TSConfig's \`"rootDir": "src"\` - require("../package.json") as typeof import("../package.json"); - - export const plugin = { - configs: { - get recommended() { - return recommended; - }, - }, - meta: { name, version }, - rules, - }; + \`\`\`shell + npm i test-repository -D + \`\`\` - const recommended = { - plugins: { - "test-repository": plugin, - }, - rules: Object.fromEntries( - Object.keys(rules).map((rule) => [\`test-repository/\${rule}\`, "error"]), - ), - }; - - export { rules }; - - export default plugin; - ", - "rules": { - "enums.test.ts": "import { rule } from "./enums.js"; - import { ruleTester } from "./ruleTester.js"; - - ruleTester.run("enums", rule, { - invalid: [ - { - code: \`enum Values {}\`, - errors: [ - { - column: 1, - endColumn: 15, - endLine: 1, - line: 1, - messageId: "enum", - }, - ], - }, - ], - valid: [\`const Values = {};\`, \`const Values = {} as const;\`], - }); - ", - "enums.ts": "import { createRule } from "../utils.js"; - - export const rule = createRule({ - create(context) { - return { - TSEnumDeclaration(node) { - context.report({ - messageId: "enum", - node, - }); - }, - }; - }, - defaultOptions: [], - meta: { - docs: { - description: "Avoid using TypeScript's enums.", - }, - messages: { - enum: "This enum will not be allowed under TypeScript's --erasableSyntaxOnly.", - }, - schema: [], - type: "problem", - }, - name: "enums", - }); - ", - "index.ts": "import { rule as enums } from "./enums.js"; - - export const rules = { - enums, - }; - ", - "ruleTester.ts": "import { RuleTester } from "@typescript-eslint/rule-tester"; - import * as vitest from "vitest"; - - RuleTester.afterAll = vitest.afterAll; - RuleTester.it = vitest.it; - RuleTester.itOnly = vitest.it.only; - RuleTester.describe = vitest.describe; - - export const ruleTester = new RuleTester(); - ", - }, - "utils.ts": "import { ESLintUtils } from "@typescript-eslint/utils"; - - export const createRule = ESLintUtils.RuleCreator( - (name) => - \`https://github.com/test-owner/test-repository/blob/main/docs/rules/\${name}.md\`, - ); - ", - }, - }, - "scripts": [ - { - "commands": [ - "pnpm build", - ], - "phase": 2, - }, - { - "commands": [ - "pnpm eslint-doc-generator --init-rule-docs", - ], - "phase": 3, - }, - ], - } - `); + \`\`\`ts + import testRepository from "test-repository"; + + export default [ + // (other plugins) + testRepository.configs.recommended, // 👈 + ]; + \`\`\` + + ### Rules + + These are all set to \`"error"\` in the recommended config: + + ", + ], + }, + "block": [Function], + }, + { + "addons": { + "properties": { + "dependencies": { + "@typescript-eslint/utils": "^8.29.0", + }, + "devDependencies": { + "@typescript-eslint/rule-tester": "8.29.1", + "eslint-doc-generator": "2.1.0", + "eslint-plugin-eslint-plugin": "6.4.0", + }, + "scripts": { + "build:docs": "pnpm build --no-dts && eslint-doc-generator", + "lint:docs": "eslint-doc-generator --check", + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "coverage": { + "exclude": [ + "src/index.ts", + "src/rules/index.ts", + ], + }, + }, + "block": [Function], + }, + ], + "files": { + ".eslint-doc-generatorrc.mjs": "import prettier from "prettier"; + + /** @type {import('eslint-doc-generator').GenerateOptions} */ + const config = { + postprocess: async (content, path) => + prettier.format(content, { + ...(await prettier.resolveConfig(path)), + parser: "markdown", + }), + ruleDocTitleFormat: "name", + }; + + export default config; + ", + }, + "scripts": [ + { + "commands": [ + "pnpm build", + ], + "phase": 2, + }, + { + "commands": [ + "pnpm eslint-doc-generator --init-rule-docs", + ], + "phase": 3, + }, + ], + } + `); }); - test("addons", () => { + test("setup mode", () => { const creation = testBlock(blockESLintPlugin, { - addons: { - configEmoji: [ - ["recommended", "✅"], - ["legacy-recommended", "✔️"], - ], - }, + mode: "setup", options: optionsBase, }); expect(creation).toMatchInlineSnapshot(` - { - "addons": [ - { - "addons": { - "words": [ - "eslint-doc-generatorrc", - ], - }, - "block": [Function], - }, - { - "addons": { - "sections": { - "Building": { - "innerSections": [ - { - "contents": " - Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. - - \`\`\`shell - pnpm build:docs - \`\`\` - ", - "heading": "Building Docs", - }, - ], - }, - "Linting": { - "contents": { - "items": [ - "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", - ], - }, - }, - }, - }, - "block": [Function], - }, - { - "addons": { - "extensions": [ - "eslintPlugin.configs["flat/recommended"]", - ], - "ignores": [ - ".eslint-doc-generatorrc.js", - "docs/rules/*/*.ts", - ], - "imports": [ - { - "source": { - "packageName": "eslint-plugin-eslint-plugin", - "version": "6.4.0", - }, - "specifier": "eslintPlugin", - }, - ], - }, - "block": [Function], - }, - { - "addons": { - "jobs": [ - { - "name": "Lint Docs", - "steps": [ - { - "run": "pnpm build || exit 0", - }, - { - "run": "pnpm lint:docs", - }, - ], - }, - ], - }, - "block": [Function], - }, - { - "addons": { - "defaultUsage": [ - "Add this plugin to the list of plugins in your [ESLint configuration file](https://eslint.org/docs/latest/use/configure/configuration-files): - - \`\`\`shell - npm i test-repository -D - \`\`\` - - \`\`\`ts - import testRepository from "test-repository"; - - export default [ - // (other plugins) - testRepository.configs.recommended, // 👈 - ]; - \`\`\` - - ### Rules - - These are all set to \`"error"\` in the recommended config: - - ", - ], - }, - "block": [Function], - }, - { - "addons": { - "properties": { - "dependencies": { - "@typescript-eslint/utils": "^8.29.0", - }, - "devDependencies": { - "@typescript-eslint/rule-tester": "8.29.1", - "eslint-doc-generator": "2.1.0", - "eslint-plugin-eslint-plugin": "6.4.0", - }, - "scripts": { - "build:docs": "pnpm build --no-dts && eslint-doc-generator", - "lint:docs": "eslint-doc-generator --check", - }, - }, - }, - "block": [Function], - }, - { - "addons": { - "coverage": { - "exclude": [ - "src/index.ts", - "src/rules/index.ts", - ], - }, - }, - "block": [Function], - }, - ], - "files": { - ".eslint-doc-generatorrc.js": "import prettier from "prettier"; - - /** @type {import('eslint-doc-generator').GenerateOptions} */ - const config = { - configEmoji: [["recommended","✅"],["legacy-recommended","✔️"]], - postprocess: async (content, path) => - prettier.format(content, { - ...(await prettier.resolveConfig(path)), - parser: "markdown", - }), - ruleDocTitleFormat: "name", - }; - - export default config; - ", - }, - "scripts": [ - { - "commands": [ - "pnpm build", - ], - "phase": 2, - }, - { - "commands": [ - "pnpm eslint-doc-generator --init-rule-docs", - ], - "phase": 3, - }, - ], - } - `); + { + "addons": [ + { + "addons": { + "words": [ + "eslint-doc-generatorrc", + ], + }, + "block": [Function], + }, + { + "addons": { + "sections": { + "Building": { + "innerSections": [ + { + "contents": " + Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. + + \`\`\`shell + pnpm build:docs + \`\`\` + ", + "heading": "Building Docs", + }, + ], + }, + "Linting": { + "contents": { + "items": [ + "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", + ], + }, + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "extensions": [ + { + "extends": [ + "eslintPlugin.configs["flat/recommended"]", + ], + "files": [ + "**/*.{js,ts}", + ], + }, + ], + "ignores": [ + ".eslint-doc-generatorrc.js", + "docs/rules/*/*.ts", + ], + "imports": [ + { + "source": { + "packageName": "eslint-plugin-eslint-plugin", + "version": "6.4.0", + }, + "specifier": "eslintPlugin", + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "jobs": [ + { + "name": "Lint Docs", + "steps": [ + { + "run": "pnpm build || exit 0", + }, + { + "run": "pnpm lint:docs", + }, + ], + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "defaultUsage": [ + "Add this plugin to the list of plugins in your [ESLint configuration file](https://eslint.org/docs/latest/use/configure/configuration-files): + + \`\`\`shell + npm i test-repository -D + \`\`\` + + \`\`\`ts + import testRepository from "test-repository"; + + export default [ + // (other plugins) + testRepository.configs.recommended, // 👈 + ]; + \`\`\` + + ### Rules + + These are all set to \`"error"\` in the recommended config: + + ", + ], + }, + "block": [Function], + }, + { + "addons": { + "properties": { + "dependencies": { + "@typescript-eslint/utils": "^8.29.0", + }, + "devDependencies": { + "@typescript-eslint/rule-tester": "8.29.1", + "eslint-doc-generator": "2.1.0", + "eslint-plugin-eslint-plugin": "6.4.0", + }, + "scripts": { + "build:docs": "pnpm build --no-dts && eslint-doc-generator", + "lint:docs": "eslint-doc-generator --check", + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "coverage": { + "exclude": [ + "src/index.ts", + "src/rules/index.ts", + ], + }, + }, + "block": [Function], + }, + ], + "files": { + ".eslint-doc-generatorrc.js": "import prettier from "prettier"; + + /** @type {import('eslint-doc-generator').GenerateOptions} */ + const config = { + postprocess: async (content, path) => + prettier.format(content, { + ...(await prettier.resolveConfig(path)), + parser: "markdown", + }), + ruleDocTitleFormat: "name", + }; + + export default config; + ", + "src": { + "index.ts": "import Module from "node:module"; + + import { rules } from "./rules/index.js"; + + const require = Module.createRequire(import.meta.url); + + const { name, version } = + // \`import\`ing here would bypass the TSConfig's \`"rootDir": "src"\` + require("../package.json") as typeof import("../package.json"); + + export const plugin = { + configs: { + get recommended() { + return recommended; + }, + }, + meta: { name, version }, + rules, + }; + + const recommended = { + plugins: { + "test-repository": plugin, + }, + rules: Object.fromEntries( + Object.keys(rules).map((rule) => [\`test-repository/\${rule}\`, "error"]), + ), + }; + + export { rules }; + + export default plugin; + ", + "rules": { + "enums.test.ts": "import { rule } from "./enums.js"; + import { ruleTester } from "./ruleTester.js"; + + ruleTester.run("enums", rule, { + invalid: [ + { + code: \`enum Values {}\`, + errors: [ + { + column: 1, + endColumn: 15, + endLine: 1, + line: 1, + messageId: "enum", + }, + ], + }, + ], + valid: [\`const Values = {};\`, \`const Values = {} as const;\`], + }); + ", + "enums.ts": "import { createRule } from "../utils.js"; + + export const rule = createRule({ + create(context) { + return { + TSEnumDeclaration(node) { + context.report({ + messageId: "enum", + node, + }); + }, + }; + }, + defaultOptions: [], + meta: { + docs: { + description: "Avoid using TypeScript's enums.", + }, + messages: { + enum: "This enum will not be allowed under TypeScript's --erasableSyntaxOnly.", + }, + schema: [], + type: "problem", + }, + name: "enums", + }); + ", + "index.ts": "import { rule as enums } from "./enums.js"; + + export const rules = { + enums, + }; + ", + "ruleTester.ts": "import { RuleTester } from "@typescript-eslint/rule-tester"; + import * as vitest from "vitest"; + + RuleTester.afterAll = vitest.afterAll; + RuleTester.it = vitest.it; + RuleTester.itOnly = vitest.it.only; + RuleTester.describe = vitest.describe; + + export const ruleTester = new RuleTester(); + ", + }, + "utils.ts": "import { ESLintUtils } from "@typescript-eslint/utils"; + + export const createRule = ESLintUtils.RuleCreator( + (name) => + \`https://github.com/test-owner/test-repository/blob/main/docs/rules/\${name}.md\`, + ); + ", + }, + }, + "scripts": [ + { + "commands": [ + "pnpm build", + ], + "phase": 2, + }, + { + "commands": [ + "pnpm eslint-doc-generator --init-rule-docs", + ], + "phase": 3, + }, + ], + } + `); + }); + + test("addons", () => { + const creation = testBlock(blockESLintPlugin, { + addons: { + configEmoji: [ + ["recommended", "✅"], + ["legacy-recommended", "✔️"], + ], + }, + options: optionsBase, + }); + + expect(creation).toMatchInlineSnapshot(` + { + "addons": [ + { + "addons": { + "words": [ + "eslint-doc-generatorrc", + ], + }, + "block": [Function], + }, + { + "addons": { + "sections": { + "Building": { + "innerSections": [ + { + "contents": " + Run [\`eslint-doc-generator\`](https://github.com/bmish/eslint-doc-generator) to generate Markdown files documenting rules. + + \`\`\`shell + pnpm build:docs + \`\`\` + ", + "heading": "Building Docs", + }, + ], + }, + "Linting": { + "contents": { + "items": [ + "- \`pnpm lint:docs\` ([eslint-doc-generator](https://github.com/bmish/eslint-doc-generator)): Generates and validates documentation for ESLint rules", + ], + }, + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "extensions": [ + { + "extends": [ + "eslintPlugin.configs["flat/recommended"]", + ], + "files": [ + "**/*.{js,ts}", + ], + }, + ], + "ignores": [ + ".eslint-doc-generatorrc.js", + "docs/rules/*/*.ts", + ], + "imports": [ + { + "source": { + "packageName": "eslint-plugin-eslint-plugin", + "version": "6.4.0", + }, + "specifier": "eslintPlugin", + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "jobs": [ + { + "name": "Lint Docs", + "steps": [ + { + "run": "pnpm build || exit 0", + }, + { + "run": "pnpm lint:docs", + }, + ], + }, + ], + }, + "block": [Function], + }, + { + "addons": { + "defaultUsage": [ + "Add this plugin to the list of plugins in your [ESLint configuration file](https://eslint.org/docs/latest/use/configure/configuration-files): + + \`\`\`shell + npm i test-repository -D + \`\`\` + + \`\`\`ts + import testRepository from "test-repository"; + + export default [ + // (other plugins) + testRepository.configs.recommended, // 👈 + ]; + \`\`\` + + ### Rules + + These are all set to \`"error"\` in the recommended config: + + ", + ], + }, + "block": [Function], + }, + { + "addons": { + "properties": { + "dependencies": { + "@typescript-eslint/utils": "^8.29.0", + }, + "devDependencies": { + "@typescript-eslint/rule-tester": "8.29.1", + "eslint-doc-generator": "2.1.0", + "eslint-plugin-eslint-plugin": "6.4.0", + }, + "scripts": { + "build:docs": "pnpm build --no-dts && eslint-doc-generator", + "lint:docs": "eslint-doc-generator --check", + }, + }, + }, + "block": [Function], + }, + { + "addons": { + "coverage": { + "exclude": [ + "src/index.ts", + "src/rules/index.ts", + ], + }, + }, + "block": [Function], + }, + ], + "files": { + ".eslint-doc-generatorrc.js": "import prettier from "prettier"; + + /** @type {import('eslint-doc-generator').GenerateOptions} */ + const config = { + configEmoji: [["recommended","✅"],["legacy-recommended","✔️"]], + postprocess: async (content, path) => + prettier.format(content, { + ...(await prettier.resolveConfig(path)), + parser: "markdown", + }), + ruleDocTitleFormat: "name", + }; + + export default config; + ", + }, + "scripts": [ + { + "commands": [ + "pnpm build", + ], + "phase": 2, + }, + { + "commands": [ + "pnpm eslint-doc-generator --init-rule-docs", + ], + "phase": 3, + }, + ], + } + `); }); describe("intake", () => { diff --git a/src/blocks/blockESLintPlugin.ts b/src/blocks/blockESLintPlugin.ts index e41ec71bf..ef8796db0 100644 --- a/src/blocks/blockESLintPlugin.ts +++ b/src/blocks/blockESLintPlugin.ts @@ -65,7 +65,12 @@ pnpm build:docs }, }), blockESLint({ - extensions: ['eslintPlugin.configs["flat/recommended"]'], + extensions: [ + { + extends: ['eslintPlugin.configs["flat/recommended"]'], + files: ["**/*.{js,ts}"], + }, + ], ignores: [configFileName, "docs/rules/*/*.ts"], imports: [ { diff --git a/src/blocks/blockESLintRegexp.ts b/src/blocks/blockESLintRegexp.ts index c33d0aa88..5110bea6d 100644 --- a/src/blocks/blockESLintRegexp.ts +++ b/src/blocks/blockESLintRegexp.ts @@ -9,7 +9,12 @@ export const blockESLintRegexp = base.createBlock({ return { addons: [ blockESLint({ - extensions: [`regexp.configs["flat/recommended"]`], + extensions: [ + { + extends: [`regexp.configs["flat/recommended"]`], + files: ["**/*.{js,ts}"], + }, + ], imports: [ { source: "eslint-plugin-regexp", specifier: "* as regexp" }, ], diff --git a/src/blocks/blockVitest.test.ts b/src/blocks/blockVitest.test.ts index 6896e8df8..556300fe4 100644 --- a/src/blocks/blockVitest.test.ts +++ b/src/blocks/blockVitest.test.ts @@ -70,6 +70,11 @@ describe("blockVitest", () => { }, }, ], + "settings": { + "vitest": { + "typecheck": true, + }, + }, }, ], "ignores": [ @@ -82,11 +87,6 @@ describe("blockVitest", () => { "specifier": "vitest", }, ], - "settings": { - "vitest": { - "typecheck": true, - }, - }, }, "block": [Function], }, @@ -316,6 +316,11 @@ describe("blockVitest", () => { }, }, ], + "settings": { + "vitest": { + "typecheck": true, + }, + }, }, ], "ignores": [ @@ -328,11 +333,6 @@ describe("blockVitest", () => { "specifier": "vitest", }, ], - "settings": { - "vitest": { - "typecheck": true, - }, - }, }, "block": [Function], }, @@ -600,6 +600,11 @@ describe("blockVitest", () => { }, }, ], + "settings": { + "vitest": { + "typecheck": true, + }, + }, }, ], "ignores": [ @@ -612,11 +617,6 @@ describe("blockVitest", () => { "specifier": "vitest", }, ], - "settings": { - "vitest": { - "typecheck": true, - }, - }, }, "block": [Function], }, @@ -855,6 +855,11 @@ describe("blockVitest", () => { }, }, ], + "settings": { + "vitest": { + "typecheck": true, + }, + }, }, ], "ignores": [ @@ -867,11 +872,6 @@ describe("blockVitest", () => { "specifier": "vitest", }, ], - "settings": { - "vitest": { - "typecheck": true, - }, - }, }, "block": [Function], }, diff --git a/src/blocks/blockVitest.ts b/src/blocks/blockVitest.ts index c11a896dc..ed658899b 100644 --- a/src/blocks/blockVitest.ts +++ b/src/blocks/blockVitest.ts @@ -122,13 +122,13 @@ Calls to \`console.log\`, \`console.warn\`, and other console methods will cause }, }, ], + settings: { + vitest: { typecheck: true }, + }, }, ], ignores: ["coverage", "**/*.snap"], imports: [{ source: "@vitest/eslint-plugin", specifier: "vitest" }], - settings: { - vitest: { typecheck: true }, - }, }), blockExampleFiles({ files: { diff --git a/src/blocks/eslint/schemas.ts b/src/blocks/eslint/schemas.ts index 5c1bf3ec8..bfe51d2b1 100644 --- a/src/blocks/eslint/schemas.ts +++ b/src/blocks/eslint/schemas.ts @@ -34,7 +34,7 @@ export type ExtensionRules = z.infer; export const zExtension = z.object({ extends: z.array(z.string()).optional(), - files: z.array(z.string()).optional(), + files: z.array(z.string()), languageOptions: z.unknown().optional(), linterOptions: z.unknown().optional(), plugins: z.record(z.string(), z.string()).optional(), From 8affb20fdf845d60e2203fac4c7d22e06a9ddd7e Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 2 Dec 2025 16:21:31 -0500 Subject: [PATCH 2/4] Move settings into block --- src/blocks/blockESLint.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/blocks/blockESLint.test.ts b/src/blocks/blockESLint.test.ts index 6aa9c670a..895bfa40d 100644 --- a/src/blocks/blockESLint.test.ts +++ b/src/blocks/blockESLint.test.ts @@ -467,6 +467,11 @@ describe("blockESLint", () => { "b/c": "error", "b/d": ["error", { e: "f" }], }, + settings: { + react: { + version: "detect", + }, + }, }, ], ignores: ["generated"], @@ -486,11 +491,6 @@ describe("blockESLint", () => { }, }, ], - settings: { - react: { - version: "detect", - }, - }, }, options: optionsBase, }); @@ -610,7 +610,7 @@ describe("blockESLint", () => { export default defineConfig( { ignores: ["generated", "lib", "node_modules", "pnpm-lock.yaml"] }, { linterOptions: { reportUnusedDisableDirectives: "error" } }, - { extends: [a.configs.recommended], files: ["**/*.a"], rules: {"a/b":"error","a/c":["error",{"d":"e"}]}, },{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: {"a/b": "error","a/c": ["error",{"d":"e"}],}, } + { extends: [a.configs.recommended], files: ["**/*.a"], rules: {"a/b":"error","a/c":["error",{"d":"e"}]}, },{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, settings: {"react":{"version":"detect"}}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: {"a/b": "error","a/c": ["error",{"d":"e"}],}, } );", }, "scripts": [ From ddffa3ebf875f0cc558c780e3e2575fd254a3db3 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 2 Dec 2025 16:49:50 -0500 Subject: [PATCH 3/4] Separate out and improve test coverage --- src/blocks/blockESLint.test.ts | 387 +++++++++++++++++-- src/blocks/blockESLint.ts | 64 +-- src/blocks/blockESLintMoreStyling.ts | 29 +- src/blocks/eslint/mergeAllExtensions.test.ts | 297 ++++++++++++++ src/blocks/eslint/mergeAllExtensions.ts | 63 +++ src/blocks/eslint/schemas.ts | 13 +- src/integration.test.ts | 46 ++- 7 files changed, 768 insertions(+), 131 deletions(-) create mode 100644 src/blocks/eslint/mergeAllExtensions.test.ts create mode 100644 src/blocks/eslint/mergeAllExtensions.ts diff --git a/src/blocks/blockESLint.test.ts b/src/blocks/blockESLint.test.ts index 895bfa40d..3d9140978 100644 --- a/src/blocks/blockESLint.test.ts +++ b/src/blocks/blockESLint.test.ts @@ -483,14 +483,6 @@ describe("blockESLint", () => { specifier: "c", }, ], - rules: [ - { - entries: { - "a/b": "error", - "a/c": ["error", { d: "e" }], - }, - }, - ], }, options: optionsBase, }); @@ -610,7 +602,7 @@ describe("blockESLint", () => { export default defineConfig( { ignores: ["generated", "lib", "node_modules", "pnpm-lock.yaml"] }, { linterOptions: { reportUnusedDisableDirectives: "error" } }, - { extends: [a.configs.recommended], files: ["**/*.a"], rules: {"a/b":"error","a/c":["error",{"d":"e"}]}, },{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, settings: {"react":{"version":"detect"}}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: {"a/b": "error","a/c": ["error",{"d":"e"}],}, } + { extends: [a.configs.recommended], files: ["**/*.a"], rules: {"a/b":"error","a/c":["error",{"d":"e"}]}, },{ extends: [b.configs.recommended], files: ["**/*.b"], rules: {"b/c":"error","b/d":["error",{"e":"f"}]}, settings: {"react":{"version":"detect"}}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ @@ -625,21 +617,43 @@ describe("blockESLint", () => { `); }); - test("with identical addon rules comments", () => { + test("with identical addon rules comments across two extensions", () => { const creation = testBlock(blockESLint, { addons: { - rules: [ - { - comment: "Duplicated comment", - entries: { a: "error" }, - }, + extensions: [ { - comment: "Standalone comment", - entries: { b: "error" }, + files: ["**/*.js"], + rules: [ + { + comment: "Duplicated comment", + entries: { a: "error" }, + }, + { + comment: "Standalone comment", + entries: { b: "error" }, + }, + { + comment: "Duplicated comment", + entries: { c: "error" }, + }, + ], }, { - comment: "Duplicated comment", - entries: { c: "error" }, + files: ["**/*.js"], + rules: [ + { + comment: "Duplicated comment", + entries: { d: "error" }, + }, + { + comment: "Standalone comment", + entries: { e: "error" }, + }, + { + comment: "Duplicated comment", + entries: { f: "error" }, + }, + ], }, ], }, @@ -746,13 +760,13 @@ describe("blockESLint", () => { export default defineConfig( { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, { linterOptions: { reportUnusedDisableDirectives: "error" } }, - { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: { + { extends: [], files: ["**/*.js"], rules: { // Duplicated comment - "a": "error","c": "error", + "a": "error","c": "error","d": "error","f": "error", // Standalone comment - "b": "error",}, } + "b": "error","e": "error",}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ @@ -767,21 +781,26 @@ describe("blockESLint", () => { `); }); - test("with multiline addon rules comment", () => { + test("with multiline addon rules comments", () => { const creation = testBlock(blockESLint, { addons: { - rules: [ - { - comment: "One line", - entries: { a: "error" }, - }, - { - comment: "Two lines\ntwo lines", - entries: { a: "error" }, - }, + extensions: [ { - comment: "Three lines\nthree lines\nthree lines", - entries: { a: "error" }, + files: ["**/*.js"], + rules: [ + { + comment: "One line", + entries: { a: "error" }, + }, + { + comment: "Two lines\ntwo lines", + entries: { a: "error" }, + }, + { + comment: "Three lines\nthree lines\nthree lines", + entries: { a: "error" }, + }, + ], }, ], }, @@ -888,7 +907,7 @@ describe("blockESLint", () => { export default defineConfig( { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, { linterOptions: { reportUnusedDisableDirectives: "error" } }, - { extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, rules: { + { files: ["**/*.js"], rules: { // One line "a": "error", @@ -900,7 +919,303 @@ describe("blockESLint", () => { // Three lines // three lines // three lines - "a": "error",}, } + "a": "error",}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } + );", + }, + "scripts": [ + { + "commands": [ + "pnpm lint --fix", + ], + "phase": 3, + }, + ], + } + `); + }); + + test("with addon extensions merging where the first provides everything", () => { + const creation = testBlock(blockESLint, { + addons: { + extensions: [ + { + extends: ["a.configs.recommended"], + files: ["**/*.a"], + languageOptions: { + languageOption: true, + }, + linterOptions: { + linterOption: true, + }, + plugins: { + "plugin-a-key": "plugin-a-value", + }, + rules: { + "a/b": "error", + }, + settings: { + react: { + version: "detect", + }, + }, + }, + { + files: ["**/*.a"], + }, + ], + }, + 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.39.1", + "@types/node": "24.10.1", + "eslint": "9.39.1", + "typescript-eslint": "8.48.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 { defineConfig } from "eslint/config"; + import tseslint from "typescript-eslint"; + + export default defineConfig( + { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [a.configs.recommended], files: ["**/*.a"], languageOptions: {"languageOption":true}, linterOptions: {"linterOption":true} rules: {"a/b":"error"}, settings: {"react":{"version":"detect"}}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } + );", + }, + "scripts": [ + { + "commands": [ + "pnpm lint --fix", + ], + "phase": 3, + }, + ], + } + `); + }); + + test("with addon extensions merging where the second provides everything", () => { + const creation = testBlock(blockESLint, { + addons: { + extensions: [ + { + files: ["**/*.a"], + }, + { + extends: ["a.configs.recommended"], + files: ["**/*.a"], + languageOptions: { + languageOption: true, + }, + linterOptions: { + linterOption: true, + }, + plugins: { + "plugin-a-key": "plugin-a-value", + }, + rules: { + "a/b": "error", + }, + settings: { + react: { + version: "detect", + }, + }, + }, + ], + }, + 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.39.1", + "@types/node": "24.10.1", + "eslint": "9.39.1", + "typescript-eslint": "8.48.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 { defineConfig } from "eslint/config"; + import tseslint from "typescript-eslint"; + + export default defineConfig( + { ignores: ["lib", "node_modules", "pnpm-lock.yaml"] }, + { linterOptions: { reportUnusedDisableDirectives: "error" } }, + { extends: [a.configs.recommended], files: ["**/*.a"], languageOptions: {"languageOption":true}, linterOptions: {"linterOption":true} rules: {"a/b":"error"}, settings: {"react":{"version":"detect"}}, },{ extends: [eslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], files: ["**/*.{js,ts}"], languageOptions: {"parserOptions":{"projectService":{"allowDefaultProject":["*.config.*s"]}}}, } );", }, "scripts": [ diff --git a/src/blocks/blockESLint.ts b/src/blocks/blockESLint.ts index 72e9bc41d..e638b6f46 100644 --- a/src/blocks/blockESLint.ts +++ b/src/blocks/blockESLint.ts @@ -13,12 +13,12 @@ import { blockRemoveFiles } from "./blockRemoveFiles.js"; import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js"; import { blockVSCode } from "./blockVSCode.js"; import { blockESLintIntake } from "./eslint/blockESLintIntake.js"; +import { mergeAllExtensions } from "./eslint/mergeAllExtensions.js"; import { Extension, ExtensionRuleGroup, ExtensionRules, zExtension, - zExtensionRules, zPackageImport, } from "./eslint/schemas.js"; import { intakeFile } from "./intake/intakeFile.js"; @@ -34,7 +34,6 @@ export const blockESLint = base.createBlock({ extensions: z.array(zExtension).default([]), ignores: z.array(z.string()).default([]), imports: z.array(zPackageImport).default([]), - rules: zExtensionRules.optional(), }, intake({ files }) { const eslintConfigRaw = intakeFile(files, [ @@ -44,7 +43,7 @@ export const blockESLint = base.createBlock({ return eslintConfigRaw ? blockESLintIntake(eslintConfigRaw[0]) : undefined; }, produce({ addons, options }) { - const { explanations, extensions, ignores, imports, rules } = addons; + const { explanations, extensions, ignores, imports } = addons; const [configFileName, fileExtensions] = options.type === "commonjs" @@ -104,7 +103,6 @@ export const blockESLint = base.createBlock({ }, }, }, - ...(rules && { rules }), }, ...extensions, ...(options.type === "commonjs" @@ -289,64 +287,6 @@ function groupByComment(rulesGroups: ExtensionRuleGroup[]) { return grouped; } -function mergeAllExtensions(...extensions: Extension[]) { - const entries: Record = {}; - - for (const extension of extensions) { - const filesKey = JSON.stringify(extension.files); - - entries[filesKey] = - filesKey in entries - ? mergeExtensions(entries[filesKey], extension, extension.files) - : extension; - } - - return Object.values(entries); -} - -function mergeExtensions( - a: Extension, - b: Extension, - files: string[], -): Extension { - return { - extends: Array.from( - new Set([...(a.extends ?? []), ...(b.extends ?? [])]), - ).sort(), - files, - languageOptions: (a.languageOptions ?? b.languageOptions) && { - ...(a.languageOptions ?? {}), - ...(b.languageOptions ?? {}), - }, - linterOptions: (a.linterOptions ?? b.linterOptions) && { - ...(a.linterOptions ?? {}), - ...(b.linterOptions ?? {}), - }, - plugins: (a.plugins ?? b.plugins) && { ...a.plugins, ...b.plugins }, - rules: mergeExtensionsRules(a.rules, b.rules), - settings: (a.settings ?? b.settings) && { ...a.settings, ...b.settings }, - }; -} - -function mergeExtensionsRules( - a: ExtensionRules | undefined, - b: ExtensionRules | undefined, -): ExtensionRules | undefined { - if (!a || !b) { - return a ?? b; - } - - if (Array.isArray(a) && Array.isArray(b)) { - return [...a, ...b]; - } - - if (!Array.isArray(a) && !Array.isArray(b)) { - return { ...a, ...b }; - } - - console.log({ a, b }); -} - function printExtension(extension: Extension) { return [ "{", diff --git a/src/blocks/blockESLintMoreStyling.ts b/src/blocks/blockESLintMoreStyling.ts index 91867bc7a..aa97cbf2d 100644 --- a/src/blocks/blockESLintMoreStyling.ts +++ b/src/blocks/blockESLintMoreStyling.ts @@ -12,19 +12,24 @@ export const blockESLintMoreStyling = base.createBlock({ return { addons: [ blockESLint({ - rules: [ + extensions: [ { - comment: stylisticComment, - entries: { - "logical-assignment-operators": [ - "error", - "always", - { enforceForIfStatements: true }, - ], - "no-useless-rename": "error", - "object-shorthand": "error", - "operator-assignment": "error", - }, + files: ["**/*.{js,ts}"], + rules: [ + { + comment: stylisticComment, + entries: { + "logical-assignment-operators": [ + "error", + "always", + { enforceForIfStatements: true }, + ], + "no-useless-rename": "error", + "object-shorthand": "error", + "operator-assignment": "error", + }, + }, + ], }, ], }), diff --git a/src/blocks/eslint/mergeAllExtensions.test.ts b/src/blocks/eslint/mergeAllExtensions.test.ts new file mode 100644 index 000000000..4ac529e18 --- /dev/null +++ b/src/blocks/eslint/mergeAllExtensions.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "vitest"; + +import { mergeAllExtensions } from "./mergeAllExtensions.js"; + +describe(mergeAllExtensions, () => { + test("when the first provides everything", () => { + const actual = mergeAllExtensions( + { + extends: ["a.configs.recommended"], + files: ["**/*.a"], + languageOptions: { + languageOption: true, + }, + linterOptions: { + linterOption: true, + }, + plugins: { + "plugin-a-key": "plugin-a-value", + }, + rules: { + "a/b": "error", + }, + settings: { + react: { + version: "detect", + }, + }, + }, + { + files: ["**/*.a"], + }, + ); + + expect(actual).toMatchInlineSnapshot(` + [ + { + "extends": [ + "a.configs.recommended", + ], + "files": [ + "**/*.a", + ], + "languageOptions": { + "languageOption": true, + }, + "linterOptions": { + "linterOption": true, + }, + "plugins": { + "plugin-a-key": "plugin-a-value", + }, + "rules": { + "a/b": "error", + }, + "settings": { + "react": { + "version": "detect", + }, + }, + }, + ] + `); + }); + + test("when the second provides everything", () => { + const actual = mergeAllExtensions( + { + files: ["**/*.a"], + }, + { + extends: ["a.configs.recommended"], + files: ["**/*.a"], + languageOptions: { + languageOption: true, + }, + linterOptions: { + linterOption: true, + }, + plugins: { + "plugin-a-key": "plugin-a-value", + }, + rules: { + "a/b": "error", + }, + settings: { + react: { + version: "detect", + }, + }, + }, + ); + + expect(actual).toMatchInlineSnapshot(` + [ + { + "extends": [ + "a.configs.recommended", + ], + "files": [ + "**/*.a", + ], + "languageOptions": { + "languageOption": true, + }, + "linterOptions": { + "linterOption": true, + }, + "plugins": { + "plugin-a-key": "plugin-a-value", + }, + "rules": { + "a/b": "error", + }, + "settings": { + "react": { + "version": "detect", + }, + }, + }, + ] + `); + }); + + test("where neither rules group has a comment", () => { + const actual = mergeAllExtensions( + { + files: ["**/*.js"], + rules: { a: "error" }, + }, + { + files: ["**/*.js"], + rules: { b: "error" }, + }, + ); + + expect(actual).toMatchInlineSnapshot(` + [ + { + "extends": [], + "files": [ + "**/*.js", + ], + "languageOptions": undefined, + "linterOptions": undefined, + "plugins": undefined, + "rules": { + "a": "error", + "b": "error", + }, + "settings": undefined, + }, + ] + `); + }); + + test("where only the first rules group has a comment", () => { + const actual = mergeAllExtensions( + { + files: ["**/*.js"], + rules: [ + { + comment: "One standalone comment", + entries: { a: "error" }, + }, + ], + }, + { + files: ["**/*.js"], + rules: { b: "error" }, + }, + ); + + expect(actual).toMatchInlineSnapshot(` + [ + { + "extends": [], + "files": [ + "**/*.js", + ], + "languageOptions": undefined, + "linterOptions": undefined, + "plugins": undefined, + "rules": [ + { + "comment": "One standalone comment", + "entries": { + "a": "error", + }, + }, + { + "entries": { + "b": "error", + }, + }, + ], + "settings": undefined, + }, + ] + `); + }); + + test("where only the second rules group has a comment", () => { + const actual = mergeAllExtensions( + { + files: ["**/*.js"], + rules: { b: "error" }, + }, + { + files: ["**/*.js"], + rules: [ + { + comment: "One standalone comment", + entries: { a: "error" }, + }, + ], + }, + ); + + expect(actual).toMatchInlineSnapshot(` + [ + { + "extends": [], + "files": [ + "**/*.js", + ], + "languageOptions": undefined, + "linterOptions": undefined, + "plugins": undefined, + "rules": [ + { + "comment": "One standalone comment", + "entries": { + "a": "error", + }, + }, + { + "entries": { + "b": "error", + }, + }, + ], + "settings": undefined, + }, + ] + `); + }); + + test("with identical comments in the same extension", () => { + const actual = mergeAllExtensions({ + files: ["**/*.js"], + rules: [ + { + comment: "Duplicated comment", + entries: { a: "error" }, + }, + { + comment: "Standalone comment", + entries: { b: "error" }, + }, + { + comment: "Duplicated comment", + entries: { c: "error" }, + }, + ], + }); + + expect(actual).toMatchInlineSnapshot(` + [ + { + "files": [ + "**/*.js", + ], + "rules": [ + { + "comment": "Duplicated comment", + "entries": { + "a": "error", + }, + }, + { + "comment": "Standalone comment", + "entries": { + "b": "error", + }, + }, + { + "comment": "Duplicated comment", + "entries": { + "c": "error", + }, + }, + ], + }, + ] + `); + }); +}); diff --git a/src/blocks/eslint/mergeAllExtensions.ts b/src/blocks/eslint/mergeAllExtensions.ts new file mode 100644 index 000000000..873220ac4 --- /dev/null +++ b/src/blocks/eslint/mergeAllExtensions.ts @@ -0,0 +1,63 @@ +import { Extension, ExtensionRules } from "./schemas.js"; + +export function mergeAllExtensions(...extensions: Extension[]) { + const entries: Record = {}; + + for (const extension of extensions) { + const filesKey = JSON.stringify(extension.files); + + entries[filesKey] = + filesKey in entries + ? mergeExtensions(entries[filesKey], extension, extension.files) + : extension; + } + + return Object.values(entries); +} + +function mergeExtensions( + a: Extension, + b: Extension, + files: string[], +): Extension { + return { + extends: Array.from( + new Set([...(a.extends ?? []), ...(b.extends ?? [])]), + ).sort(), + files, + languageOptions: (a.languageOptions ?? b.languageOptions) && { + ...(a.languageOptions ?? {}), + ...(b.languageOptions ?? {}), + }, + linterOptions: (a.linterOptions ?? b.linterOptions) && { + ...(a.linterOptions ?? {}), + ...(b.linterOptions ?? {}), + }, + plugins: (a.plugins ?? b.plugins) && { ...a.plugins, ...b.plugins }, + rules: mergeExtensionsRules(a.rules, b.rules), + settings: (a.settings ?? b.settings) && { ...a.settings, ...b.settings }, + }; +} + +function mergeExtensionsRules( + a: ExtensionRules | undefined, + b: ExtensionRules | undefined, +): ExtensionRules | undefined { + if (!a || !b) { + return a ?? b; + } + + if (Array.isArray(a)) { + if (Array.isArray(b)) { + return [...a, ...b]; + } + + return [...a, { entries: b }]; + } + + if (Array.isArray(b)) { + return [...b, { entries: a }]; + } + + return { ...a, ...b }; +} diff --git a/src/blocks/eslint/schemas.ts b/src/blocks/eslint/schemas.ts index bfe51d2b1..e563fda37 100644 --- a/src/blocks/eslint/schemas.ts +++ b/src/blocks/eslint/schemas.ts @@ -25,10 +25,15 @@ export const zExtensionRuleGroup = z.object({ export type ExtensionRuleGroup = z.infer; -export const zExtensionRules = z.union([ - z.record(z.string(), zRuleOptions), - z.array(zExtensionRuleGroup), -]); +export const zRulesArray = z.array(zExtensionRuleGroup); + +export type RulesArray = z.infer; + +export const zRulesRecord = z.record(z.string(), zRuleOptions); + +export type RulesRecord = z.infer; + +export const zExtensionRules = z.union([zRulesArray, zRulesRecord]); export type ExtensionRules = z.infer; diff --git a/src/integration.test.ts b/src/integration.test.ts index 1d840e8df..1aeffbaa6 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -79,24 +79,36 @@ If you're interested in learning more, see the 'getting started' docs on: - ESLint: https://eslint.org - typescript-eslint: https://typescript-eslint.io`, ], - rules: [ + extensions: [ { - comment: - "These on-by-default rules work well for this repo if configured", - entries: { - "@typescript-eslint/prefer-nullish-coalescing": [ - "error", - { ignorePrimitives: true }, - ], - "@typescript-eslint/restrict-template-expressions": [ - "error", - { allowBoolean: true, allowNullish: true, allowNumber: true }, - ], - "n/no-unsupported-features/node-builtins": [ - "error", - { allowExperimental: true, ignores: ["import.meta.dirname"] }, - ], - }, + files: ["**/*.{js,ts}"], + rules: [ + { + comment: + "These on-by-default rules work well for this repo if configured", + entries: { + "@typescript-eslint/prefer-nullish-coalescing": [ + "error", + { ignorePrimitives: true }, + ], + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowBoolean: true, + allowNullish: true, + allowNumber: true, + }, + ], + "n/no-unsupported-features/node-builtins": [ + "error", + { + allowExperimental: true, + ignores: ["import.meta.dirname"], + }, + ], + }, + }, + ], }, ], }), From 06ed6f50f7c0655dda04df3d98393a08de1292ad Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 2 Dec 2025 16:59:15 -0500 Subject: [PATCH 4/4] Always use the same file extension group --- src/blocks/blockESLint.ts | 9 ++++----- src/blocks/blockESLintComments.ts | 5 +++-- src/blocks/blockESLintJSDoc.ts | 5 +++-- src/blocks/blockESLintMoreStyling.ts | 5 +++-- src/blocks/blockESLintNode.ts | 5 +++-- src/blocks/blockESLintPerfectionist.ts | 5 +++-- src/blocks/blockESLintPlugin.test.ts | 2 +- src/blocks/blockESLintPlugin.ts | 3 ++- src/blocks/blockESLintRegexp.ts | 5 +++-- src/blocks/eslint/getScriptFileExtension.ts | 7 +++++++ 10 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 src/blocks/eslint/getScriptFileExtension.ts diff --git a/src/blocks/blockESLint.ts b/src/blocks/blockESLint.ts index e638b6f46..14d14e146 100644 --- a/src/blocks/blockESLint.ts +++ b/src/blocks/blockESLint.ts @@ -13,6 +13,7 @@ import { blockRemoveFiles } from "./blockRemoveFiles.js"; import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js"; import { blockVSCode } from "./blockVSCode.js"; import { blockESLintIntake } from "./eslint/blockESLintIntake.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; import { mergeAllExtensions } from "./eslint/mergeAllExtensions.js"; import { Extension, @@ -45,10 +46,8 @@ export const blockESLint = base.createBlock({ produce({ addons, options }) { const { explanations, extensions, ignores, imports } = addons; - const [configFileName, fileExtensions] = - options.type === "commonjs" - ? ["eslint.config.mjs", "js,mjs,ts"] - : ["eslint.config.js", "js,ts"]; + const configFileName = + options.type === "commonjs" ? "eslint.config.mjs" : "eslint.config.js"; const explanation = explanations.length > 0 @@ -84,7 +83,7 @@ export const blockESLint = base.createBlock({ "tseslint.configs.strictTypeChecked", "tseslint.configs.stylisticTypeChecked", ], - files: [`**/*.{${fileExtensions}}`], + files: [getScriptFileExtension(options)], languageOptions: { parserOptions: { projectService: { diff --git a/src/blocks/blockESLintComments.ts b/src/blocks/blockESLintComments.ts index 0c726b4aa..9230653bf 100644 --- a/src/blocks/blockESLintComments.ts +++ b/src/blocks/blockESLintComments.ts @@ -1,18 +1,19 @@ import { base } from "../base.js"; import { blockESLint } from "./blockESLint.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; export const blockESLintComments = base.createBlock({ about: { name: "ESLint Comments Plugin", }, - produce() { + produce({ options }) { return { addons: [ blockESLint({ extensions: [ { extends: ["comments.recommended"], - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], }, ], imports: [ diff --git a/src/blocks/blockESLintJSDoc.ts b/src/blocks/blockESLintJSDoc.ts index 0dc935a3a..80cdd4a50 100644 --- a/src/blocks/blockESLintJSDoc.ts +++ b/src/blocks/blockESLintJSDoc.ts @@ -1,11 +1,12 @@ import { base } from "../base.js"; import { blockESLint } from "./blockESLint.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; export const blockESLintJSDoc = base.createBlock({ about: { name: "ESLint JSDoc Plugin", }, - produce() { + produce({ options }) { return { addons: [ blockESLint({ @@ -16,7 +17,7 @@ export const blockESLintJSDoc = base.createBlock({ 'jsdoc.configs["flat/logical-typescript-error"]', 'jsdoc.configs["flat/stylistic-typescript-error"]', ], - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], }, ], imports: [{ source: "eslint-plugin-jsdoc", specifier: "jsdoc" }], diff --git a/src/blocks/blockESLintMoreStyling.ts b/src/blocks/blockESLintMoreStyling.ts index aa97cbf2d..aef13e159 100644 --- a/src/blocks/blockESLintMoreStyling.ts +++ b/src/blocks/blockESLintMoreStyling.ts @@ -1,5 +1,6 @@ import { base } from "../base.js"; import { blockESLint } from "./blockESLint.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; export const stylisticComment = "Stylistic concerns that don't interfere with Prettier"; @@ -8,13 +9,13 @@ export const blockESLintMoreStyling = base.createBlock({ about: { name: "ESLint More Styling", }, - produce() { + produce({ options }) { return { addons: [ blockESLint({ extensions: [ { - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], rules: [ { comment: stylisticComment, diff --git a/src/blocks/blockESLintNode.ts b/src/blocks/blockESLintNode.ts index 786c0f6f2..c64e6ba78 100644 --- a/src/blocks/blockESLintNode.ts +++ b/src/blocks/blockESLintNode.ts @@ -1,18 +1,19 @@ import { base } from "../base.js"; import { blockESLint } from "./blockESLint.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; export const blockESLintNode = base.createBlock({ about: { name: "ESLint Node Plugin", }, - produce() { + produce({ options }) { return { addons: [ blockESLint({ extensions: [ { extends: ['n.configs["flat/recommended"]'], - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], }, { extends: ["tseslint.configs.disableTypeChecked"], diff --git a/src/blocks/blockESLintPerfectionist.ts b/src/blocks/blockESLintPerfectionist.ts index 3e53de595..ffefb8bc9 100644 --- a/src/blocks/blockESLintPerfectionist.ts +++ b/src/blocks/blockESLintPerfectionist.ts @@ -1,18 +1,19 @@ import { base } from "../base.js"; import { blockESLint } from "./blockESLint.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; export const blockESLintPerfectionist = base.createBlock({ about: { name: "ESLint Perfectionist Plugin", }, - produce() { + produce({ options }) { return { addons: [ blockESLint({ extensions: [ { extends: [`perfectionist.configs["recommended-natural"]`], - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], settings: { perfectionist: { partitionByComment: true, diff --git a/src/blocks/blockESLintPlugin.test.ts b/src/blocks/blockESLintPlugin.test.ts index 61b13e886..031e7b4c6 100644 --- a/src/blocks/blockESLintPlugin.test.ts +++ b/src/blocks/blockESLintPlugin.test.ts @@ -242,7 +242,7 @@ describe("blockESLintPlugin", () => { "eslintPlugin.configs["flat/recommended"]", ], "files": [ - "**/*.{js,ts}", + "**/*.{js,mjs,ts}", ], }, ], diff --git a/src/blocks/blockESLintPlugin.ts b/src/blocks/blockESLintPlugin.ts index ef8796db0..ab56081f2 100644 --- a/src/blocks/blockESLintPlugin.ts +++ b/src/blocks/blockESLintPlugin.ts @@ -7,6 +7,7 @@ import { blockPackageJson } from "./blockPackageJson.js"; import { blockREADME } from "./blockREADME.js"; import { blockVitest } from "./blockVitest.js"; import { blockESLintPluginIntake } from "./eslint/blockESLintPluginIntake.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; import { zConfigEmoji } from "./eslint/schemas.js"; import { intakeFile } from "./intake/intakeFile.js"; import { CommandPhase } from "./phases.js"; @@ -68,7 +69,7 @@ pnpm build:docs extensions: [ { extends: ['eslintPlugin.configs["flat/recommended"]'], - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], }, ], ignores: [configFileName, "docs/rules/*/*.ts"], diff --git a/src/blocks/blockESLintRegexp.ts b/src/blocks/blockESLintRegexp.ts index 5110bea6d..1118573a5 100644 --- a/src/blocks/blockESLintRegexp.ts +++ b/src/blocks/blockESLintRegexp.ts @@ -1,18 +1,19 @@ import { base } from "../base.js"; import { blockESLint } from "./blockESLint.js"; +import { getScriptFileExtension } from "./eslint/getScriptFileExtension.js"; export const blockESLintRegexp = base.createBlock({ about: { name: "ESLint Regexp Plugin", }, - produce() { + produce({ options }) { return { addons: [ blockESLint({ extensions: [ { extends: [`regexp.configs["flat/recommended"]`], - files: ["**/*.{js,ts}"], + files: [getScriptFileExtension(options)], }, ], imports: [ diff --git a/src/blocks/eslint/getScriptFileExtension.ts b/src/blocks/eslint/getScriptFileExtension.ts new file mode 100644 index 000000000..c6e4ab542 --- /dev/null +++ b/src/blocks/eslint/getScriptFileExtension.ts @@ -0,0 +1,7 @@ +export interface ScriptFileExtensionOptions { + type?: "commonjs" | "module"; +} + +export function getScriptFileExtension(options: ScriptFileExtensionOptions) { + return options.type === "commonjs" ? "**/*.{js,mjs,ts}" : "**/*.{js,ts}"; +}