diff --git a/package-lock.json b/package-lock.json index fc7c77c..63a7042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "@code-pushup/eslint-config-workspace", "devDependencies": { + "@babel/types": "^7.25.0", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@commitlint/config-nx-scopes": "^20.5.0", @@ -53,6 +54,7 @@ "jsonc-eslint-parser": "^3.1.0", "nx": "22.6.4", "prettier": "~3.6.2", + "recast": "^0.23.11", "semver": "^7.7.4", "tslib": "^2.3.0", "typescript": "^5.6.3", @@ -9588,7 +9590,6 @@ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -18512,7 +18513,6 @@ "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", @@ -18530,7 +18530,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20148,8 +20147,7 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", diff --git a/package.json b/package.json index bbc5a5c..2fce0c0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ } }, "devDependencies": { + "@babel/types": "^7.25.0", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@commitlint/config-nx-scopes": "^20.5.0", @@ -59,6 +60,7 @@ "jsonc-eslint-parser": "^3.1.0", "nx": "22.6.4", "prettier": "~3.6.2", + "recast": "^0.23.11", "semver": "^7.7.4", "tslib": "^2.3.0", "typescript": "^5.6.3", diff --git a/packages/create-eslint-config/package.json b/packages/create-eslint-config/package.json index 48dbbe2..4d5729a 100644 --- a/packages/create-eslint-config/package.json +++ b/packages/create-eslint-config/package.json @@ -26,8 +26,18 @@ }, "homepage": "https://github.com/code-pushup/eslint-config/tree/main/packages/create-eslint-config#readme", "dependencies": { + "@babel/types": "^7.25.0", "@inquirer/prompts": "^8.4.1", + "recast": "^0.23.11", "semver": "^7.7.4", "yargs": "^18.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } } } diff --git a/packages/create-eslint-config/src/cli.ts b/packages/create-eslint-config/src/cli.ts index 3075893..d6a388d 100644 --- a/packages/create-eslint-config/src/cli.ts +++ b/packages/create-eslint-config/src/cli.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { logError, formatError, logChanges, logInfo } from './lib/output.js'; @@ -6,7 +5,6 @@ import { detectPackageManager, installDependencies, } from './lib/package-manager.js'; -import { validateConfigSlugs } from './lib/prompts.js'; import { NODE_VERSION_SOURCES } from './lib/types.js'; import { runSetupWizard } from './lib/wizard.js'; @@ -42,12 +40,6 @@ const argv = await yargs(hideBin(process.argv)) default: false, describe: 'Show what would happen without writing or installing', }) - .check(parsed => { - if (parsed.configs) { - validateConfigSlugs(parsed.configs); - } - return true; - }) .strict() .help() .version() @@ -68,7 +60,7 @@ try { logChanges(result.files); if (argv.dryRun) { - logInfo('Dry run — no files written.'); + logInfo('Dry run. No files written.'); } else { await result.flush(); const manager = await detectPackageManager(targetDir); @@ -83,21 +75,10 @@ try { process.exitCode = 1; } } - - // TODO: remove snippet output once the wizard can merge into existing configs - if (result.manualSnippet) { - const filename = result.manualSnippetPath - ? path.basename(result.manualSnippetPath) - : 'eslint.config.js'; - logInfo( - `Existing ${filename} detected. Here are the imports and config entries for your selections — merge manually (v1 will do this automatically):`, - '', - result.manualSnippet, - ); - } else if (!argv.dryRun && !process.exitCode) { + if (!argv.dryRun && !process.exitCode) { logInfo('Next step: run `npx eslint .` to verify the setup.'); } } catch (error) { - logError(formatError(error)); + logError('Setup wizard failed.', formatError(error)); process.exitCode = 1; } diff --git a/packages/create-eslint-config/src/index.ts b/packages/create-eslint-config/src/index.ts index 6e99250..e01bf1f 100644 --- a/packages/create-eslint-config/src/index.ts +++ b/packages/create-eslint-config/src/index.ts @@ -1,7 +1,9 @@ export { runSetupWizard } from './lib/wizard.js'; -export type { - FileChange, - NodeVersionSource, - WizardOptions, - WizardResult, +export { WizardError } from './lib/errors.js'; +export { + NODE_VERSION_SOURCES, + type FileChange, + type NodeVersionSource, + type WizardOptions, + type WizardResult, } from './lib/types.js'; diff --git a/packages/create-eslint-config/src/lib/codegen.spec.ts b/packages/create-eslint-config/src/lib/codegen.spec.ts deleted file mode 100644 index 0fa4bb2..0000000 --- a/packages/create-eslint-config/src/lib/codegen.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - generateEslintConfigSnippet, - generateEslintConfigSource, -} from './codegen.js'; - -describe('generateEslintConfigSource', () => { - it('should render a javascript-only config', () => { - const source = generateEslintConfigSource(['javascript']); - expect(source).toMatchInlineSnapshot(` - "import javascript from '@code-pushup/eslint-config/javascript.js'; - import { defineConfig } from 'eslint/config'; - - export default defineConfig( - ...javascript, - ); - " - `); - }); - - it('should render a typescript config with the tsconfig block', () => { - const source = generateEslintConfigSource(['javascript', 'typescript'], { - typescript: { tsconfigPath: 'tsconfig.json' }, - }); - expect(source).toMatchInlineSnapshot(` - "import typescript from '@code-pushup/eslint-config/typescript.js'; - import { defineConfig } from 'eslint/config'; - - export default defineConfig( - ...typescript, - { - files: ['**/*.ts'], - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - settings: { - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: 'tsconfig.json', - }, - }, - }, - }, - ); - " - `); - }); - - it('should render a node config with a manually entered version', () => { - const source = generateEslintConfigSource(['javascript', 'node'], { - node: { source: 'manual', version: '>=20.0.0' }, - }); - expect(source).toContain("version: '>=20.0.0'"); - expect(source).not.toContain('import fs'); - }); - - it('should render a node config reading from .node-version', () => { - const source = generateEslintConfigSource(['javascript', 'node'], { - node: { source: 'node-version', version: '22.1.0' }, - }); - expect(source).toContain("import fs from 'node:fs';"); - expect(source).toContain("fs.readFileSync('.node-version', 'utf8').trim()"); - }); - - it('should skip the settings.node block when engines.node is used', () => { - const source = generateEslintConfigSource(['javascript', 'node'], { - node: { source: 'engines', version: '>=18' }, - }); - expect(source).not.toContain('settings'); - expect(source).not.toContain('import fs'); - }); - - it('should camelCase hyphenated slugs into valid JS identifiers', () => { - const source = generateEslintConfigSource(['react-testing-library']); - expect(source).toContain( - "import reactTestingLibrary from '@code-pushup/eslint-config/react-testing-library.js';", - ); - expect(source).toContain('...reactTestingLibrary,'); - }); - - it('should sort spreads in registry order regardless of input order', () => { - const a = generateEslintConfigSource([ - 'vitest', - 'javascript', - 'typescript', - ]); - const b = generateEslintConfigSource([ - 'javascript', - 'typescript', - 'vitest', - ]); - expect(a).toBe(b); - expect(a.indexOf('...javascript')).toBeLessThan(a.indexOf('...typescript')); - expect(a.indexOf('...typescript')).toBeLessThan(a.indexOf('...vitest')); - }); -}); - -describe('generateEslintConfigSnippet', () => { - it('should render only the body portion without defineConfig wrapper', () => { - const snippet = generateEslintConfigSnippet(['javascript', 'vitest']); - expect(snippet).toMatchInlineSnapshot(` - "import javascript from '@code-pushup/eslint-config/javascript.js'; - import vitest from '@code-pushup/eslint-config/vitest.js'; - - // Add these entries inside your defineConfig(...) call (or flat config array): - ...javascript, - ...vitest, - " - `); - }); -}); diff --git a/packages/create-eslint-config/src/lib/codegen.ts b/packages/create-eslint-config/src/lib/codegen.ts deleted file mode 100644 index 6bdccc0..0000000 --- a/packages/create-eslint-config/src/lib/codegen.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { excludeAncestors } from './config-registry.js'; -import type { - CodegenSetup, - ImportDeclarationStructure, - NodeSetup, - PackageJson, - PeerDep, - TypescriptSetup, -} from './types.js'; -import { singleQuote } from './utils.js'; - -export class CodeBuilder { - private lines: string[] = []; - - addLine(text: string, depth = 0): void { - this.lines.push(`${' '.repeat(depth)}${text}`); - } - - addLines(texts: string[], depth = 0): void { - texts.forEach(text => { - this.addLine(text, depth); - }); - } - - addEmptyLine(): void { - this.lines.push(''); - } - - toString(): string { - return `${this.lines.join('\n')}\n`; - } -} - -/** - * Generates a complete `eslint.config.(m)js` for a fresh project. Parent - * presets are subsumed (e.g., picking `typescript` drops `javascript`). - */ -export function generateEslintConfigSource( - slugs: string[], - setup: CodegenSetup = {}, -): string { - const effective = excludeAncestors(slugs); - const imports = collectImports(effective, setup); - const entries = formatConfigEntries(effective, setup); - const builder = new CodeBuilder(); - - builder.addLines(imports.map(formatImport)); - builder.addLine( - formatImport({ - moduleSpecifier: 'eslint/config', - namedImports: ['defineConfig'], - }), - ); - builder.addEmptyLine(); - builder.addLine('export default defineConfig('); - builder.addLines(entries, 1); - builder.addLine(');'); - - return builder.toString(); -} - -/** - * Generates imports and config entries to paste into an existing - * `eslint.config.*`. TODO: drop once the wizard can merge into existing configs. - */ -export function generateEslintConfigSnippet( - slugs: string[], - setup: CodegenSetup = {}, -): string { - const effective = excludeAncestors(slugs); - const imports = collectImports(effective, setup); - const entries = formatConfigEntries(effective, setup); - const builder = new CodeBuilder(); - - builder.addLines(imports.map(formatImport)); - builder.addEmptyLine(); - builder.addLine( - '// Add these entries inside your defineConfig(...) call (or flat config array):', - ); - builder.addLines(entries); - - return builder.toString(); -} - -function collectImports( - slugs: string[], - setup: CodegenSetup, -): ImportDeclarationStructure[] { - const imports: ImportDeclarationStructure[] = slugs.map(slug => ({ - moduleSpecifier: `@code-pushup/eslint-config/${slug}.js`, - defaultImport: toIdentifier(slug), - })); - if (setup.node?.source === 'node-version') { - imports.push({ moduleSpecifier: 'node:fs', defaultImport: 'fs' }); - } - return sortImports(imports); -} - -function formatConfigEntries(slugs: string[], setup: CodegenSetup): string[] { - return [ - ...slugs.map(slug => `...${toIdentifier(slug)},`), - ...typescriptEntries(setup.typescript), - ...nodeEntry(setup.node), - ]; -} - -function formatImport({ - moduleSpecifier, - defaultImport, - namedImports, -}: ImportDeclarationStructure): string { - const named = namedImports?.length ? `{ ${namedImports.join(', ')} }` : ''; - const bindings = [defaultImport, named].filter(Boolean).join(', '); - const from = bindings ? `${bindings} from ` : ''; - return `import ${from}${singleQuote(moduleSpecifier)};`; -} - -function sortImports( - imports: ImportDeclarationStructure[], -): ImportDeclarationStructure[] { - return imports.toSorted((a, b) => - a.moduleSpecifier.localeCompare(b.moduleSpecifier), - ); -} - -function typescriptEntries(ts: TypescriptSetup | undefined): string[] { - if (!ts) { - return []; - } - return [ - '{', - ` files: [${singleQuote('**/*.ts')}],`, - ' languageOptions: {', - ' parserOptions: {', - ' projectService: true,', - ' tsconfigRootDir: import.meta.dirname,', - ' },', - ' },', - '},', - '{', - ' settings: {', - ` ${singleQuote('import/resolver')}: {`, - ' typescript: {', - ' alwaysTryTypes: true,', - ` project: ${singleQuote(ts.tsconfigPath)},`, - ' },', - ' },', - ' },', - '},', - ]; -} - -/** - * Emits the settings.node.version block. Returns empty for the engines - * source, which eslint-plugin-n reads from package.json directly. - */ -function nodeEntry(node: NodeSetup | undefined): string[] { - if (!node || node.source === 'engines') { - return []; - } - return [ - '{', - ' settings: {', - ' node: {', - ` version: ${nodeVersionExpression(node)},`, - ' },', - ' },', - '},', - ]; -} - -function nodeVersionExpression(node: NodeSetup): string { - if (node.source === 'node-version') { - return `${readFileSyncCall('.node-version')}.trim()`; - } - return singleQuote(node.version); -} - -function readFileSyncCall(filePath: string): string { - return `fs.readFileSync(${singleQuote(filePath)}, ${singleQuote('utf8')})`; -} - -/** CamelCase JS identifier for a slug. */ -function toIdentifier(slug: string): string { - return slug.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase()); -} - -export function generatePackageJson( - current: PackageJson, - deps: PeerDep[], - followUps: CodegenSetup, -): string { - const updated: PackageJson = { - ...current, - devDependencies: { - ...current.devDependencies, - ...Object.fromEntries(deps.map(dep => [dep.name, dep.version])), - }, - }; - if (followUps.node?.source === 'engines') { - updated.engines = { ...current.engines, node: followUps.node.version }; - } - return `${JSON.stringify(updated, null, 2)}\n`; -} diff --git a/packages/create-eslint-config/src/lib/codegen/builders.ts b/packages/create-eslint-config/src/lib/codegen/builders.ts new file mode 100644 index 0000000..bf2aa30 --- /dev/null +++ b/packages/create-eslint-config/src/lib/codegen/builders.ts @@ -0,0 +1,187 @@ +import * as t from '@babel/types'; +import recast from 'recast'; +import babelTsParser from 'recast/parsers/babel-ts.js'; +import { excludeAncestors } from '../config-registry.js'; +import type { + CodegenSetup, + ImportDeclarationStructure, + NodeSetup, + TypescriptSetup, +} from '../types.js'; +import { singleQuote } from '../utils.js'; + +export const PRESET_MODULE_PREFIX = '@code-pushup/eslint-config/'; + +export type FlatConfigEntry = { + ast: t.SpreadElement | t.ObjectExpression; + matches: (existing: t.Node) => boolean; +}; + +export function buildEntries( + slugs: string[], + setup: CodegenSetup, + existingImports: Map, +): FlatConfigEntry[] { + return [ + ...excludeAncestors(slugs).map(slug => + buildPresetSpread(slug, existingImports), + ), + ...buildTypescriptEntries(setup.typescript), + ...buildNodeEntries(setup.node), + ]; +} + +export function buildImports( + slugs: string[], + setup: CodegenSetup, +): ImportDeclarationStructure[] { + const presetImports: ImportDeclarationStructure[] = excludeAncestors( + slugs, + ).map(slug => ({ + moduleSpecifier: `${PRESET_MODULE_PREFIX}${slug}.js`, + defaultImport: toIdentifier(slug), + })); + const extras: ImportDeclarationStructure[] = + setup.node?.source === 'node-version' + ? [{ moduleSpecifier: 'node:fs', defaultImport: 'fs' }] + : []; + return [...presetImports, ...extras].toSorted((a, b) => + a.moduleSpecifier.localeCompare(b.moduleSpecifier), + ); +} + +export function buildImportDeclaration({ + moduleSpecifier, + defaultImport, + namedImports, +}: ImportDeclarationStructure): t.ImportDeclaration { + const defaultSpec = defaultImport + ? [t.importDefaultSpecifier(t.identifier(defaultImport))] + : []; + const namedSpecs = (namedImports ?? []).map(name => + t.importSpecifier(t.identifier(name), t.identifier(name)), + ); + return t.importDeclaration( + [...defaultSpec, ...namedSpecs], + t.stringLiteral(moduleSpecifier), + ); +} + +function buildPresetSpread( + slug: string, + existingImports: Map, +): FlatConfigEntry { + const localName = + existingImports.get(`${PRESET_MODULE_PREFIX}${slug}.js::default`) ?? + toIdentifier(slug); + return { + ast: t.spreadElement(t.identifier(localName)), + matches: existing => + t.isSpreadElement(existing) && + t.isIdentifier(existing.argument) && + existing.argument.name === localName, + }; +} + +function buildTypescriptEntries( + ts: TypescriptSetup | undefined, +): FlatConfigEntry[] { + if (!ts) { + return []; + } + const filesBlock = parseObjectExpression(`{ + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }`); + const settingsBlock = parseObjectExpression(`{ + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: ${singleQuote(ts.tsconfigPath)}, + }, + }, + }, + }`); + return [filesBlock, settingsBlock].map(toObjectEntry); +} + +/** Skips the `engines` source, since eslint-plugin-n reads engines.node from package.json directly. */ +function buildNodeEntries(node: NodeSetup | undefined): FlatConfigEntry[] { + if (!node || node.source === 'engines') { + return []; + } + const versionExpression = + node.source === 'node-version' + ? `fs.readFileSync(${singleQuote('.node-version')}, ${singleQuote('utf8')}).trim()` + : singleQuote(node.version); + const block = parseObjectExpression(`{ + settings: { + node: { + version: ${versionExpression}, + }, + }, + }`); + return [toObjectEntry(block)]; +} + +function toObjectEntry(ast: t.ObjectExpression): FlatConfigEntry { + const shape = keyShape(ast); + return { + ast, + matches: existing => + t.isObjectExpression(existing) && keyShape(existing) === shape, + }; +} + +/** Keeps the option-block templates readable instead of nesting Babel builders. */ +function parseObjectExpression(source: string): t.ObjectExpression { + const file = recast.parse(`(${source});\n`, { + parser: babelTsParser, + }) as t.File; + const statement = file.program.body[0] as t.ExpressionStatement; + return statement.expression as t.ObjectExpression; +} + +/** Sorted key paths only; values are ignored so user edits don't break dedup. */ +function keyShape(objectLiteral: t.ObjectExpression): string { + return collectPropertyKeys(objectLiteral, '').toSorted().join('|'); +} + +function collectPropertyKeys( + objectLiteral: t.ObjectExpression, + prefix: string, +): string[] { + return objectLiteral.properties.flatMap(property => { + if (!t.isObjectProperty(property)) { + return []; + } + const key = getPropertyName(property.key); + if (!key) { + return []; + } + const propertyPath = prefix ? `${prefix}.${key}` : key; + return t.isObjectExpression(property.value) + ? [propertyPath, ...collectPropertyKeys(property.value, propertyPath)] + : [propertyPath]; + }); +} + +function getPropertyName(node: t.Node): string | null { + if (t.isIdentifier(node)) { + return node.name; + } + if (t.isStringLiteral(node)) { + return node.value; + } + return null; +} + +function toIdentifier(slug: string): string { + return slug.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); +} diff --git a/packages/create-eslint-config/src/lib/codegen/index.spec.ts b/packages/create-eslint-config/src/lib/codegen/index.spec.ts new file mode 100644 index 0000000..94d1b18 --- /dev/null +++ b/packages/create-eslint-config/src/lib/codegen/index.spec.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; +import { WizardError } from '../errors.js'; +import { generateEslintConfig, extendEslintConfig } from './index.js'; + +describe('generateEslintConfig', () => { + it('should render a javascript-only config', async () => { + const source = await generateEslintConfig(['javascript']); + expect(source).toMatchInlineSnapshot(` + "import { defineConfig } from 'eslint/config'; + import javascript from '@code-pushup/eslint-config/javascript.js'; + export default defineConfig(...javascript); + " + `); + }); + + it('should render a typescript config with the tsconfig block', async () => { + const source = await generateEslintConfig(['javascript', 'typescript'], { + typescript: { tsconfigPath: 'tsconfig.json' }, + }); + expect(source).toMatchInlineSnapshot(` + "import { defineConfig } from 'eslint/config'; + import typescript from '@code-pushup/eslint-config/typescript.js'; + export default defineConfig( + ...typescript, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: 'tsconfig.json', + }, + }, + }, + }, + ); + " + `); + }); + + it('should render a node config with a manually entered version', async () => { + const source = await generateEslintConfig(['javascript', 'node'], { + node: { source: 'manual', version: '>=20.0.0' }, + }); + expect(source).toContain("version: '>=20.0.0'"); + expect(source).not.toContain('import fs'); + }); + + it('should render a node config reading from .node-version', async () => { + const source = await generateEslintConfig(['javascript', 'node'], { + node: { source: 'node-version', version: '22.1.0' }, + }); + expect(source).toContain("import fs from 'node:fs';"); + expect(source).toContain("fs.readFileSync('.node-version', 'utf8').trim()"); + }); + + it('should skip the settings.node block when engines.node is used', async () => { + const source = await generateEslintConfig(['javascript', 'node'], { + node: { source: 'engines', version: '>=18' }, + }); + expect(source).not.toContain('settings'); + expect(source).not.toContain('import fs'); + }); + + it('should camelCase hyphenated slugs into valid JS identifiers', async () => { + const source = await generateEslintConfig(['react-testing-library']); + expect(source).toContain( + "import reactTestingLibrary from '@code-pushup/eslint-config/react-testing-library.js';", + ); + expect(source).toContain('...reactTestingLibrary'); + }); + + it('should sort spreads in registry order regardless of input order', async () => { + const a = await generateEslintConfig([ + 'vitest', + 'javascript', + 'typescript', + ]); + const b = await generateEslintConfig([ + 'javascript', + 'typescript', + 'vitest', + ]); + expect(a).toBe(b); + expect(a.indexOf('...javascript')).toBeLessThan(a.indexOf('...typescript')); + expect(a.indexOf('...typescript')).toBeLessThan(a.indexOf('...vitest')); + }); +}); + +describe('extendEslintConfig', () => { + it('should append imports and entries to an existing defineConfig call', async () => { + const source = [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig(', + " { rules: { 'no-console': 'warn' } },", + ');', + '', + ].join('\n'); + + await expect(extendEslintConfig(source, ['javascript'])).resolves + .toMatchInlineSnapshot(` + "import { defineConfig } from 'eslint/config'; + + import javascript from '@code-pushup/eslint-config/javascript.js'; + + export default defineConfig({ rules: { 'no-console': 'warn' } }, ...javascript); + " + `); + }); + + it('should append imports and entries to an existing tseslint.config call', async () => { + const source = [ + "import tseslint from 'typescript-eslint';", + '', + 'export default tseslint.config(', + " { rules: { 'no-console': 'warn' } },", + ');', + '', + ].join('\n'); + + await expect(extendEslintConfig(source, ['javascript'])).resolves + .toMatchInlineSnapshot(` + "import tseslint from 'typescript-eslint'; + + import javascript from '@code-pushup/eslint-config/javascript.js'; + + export default tseslint.config( + { rules: { 'no-console': 'warn' } }, + ...javascript, + ); + " + `); + }); + + it('should append entries to an array-literal default export', async () => { + const source = [ + 'export default [', + ' {', + " files: ['**/*.js'],", + ' },', + '];', + '', + ].join('\n'); + + await expect(extendEslintConfig(source, ['javascript'])).resolves + .toMatchInlineSnapshot(` + "import javascript from '@code-pushup/eslint-config/javascript.js'; + export default [ + { + files: ['**/*.js'], + }, + ...javascript, + ]; + " + `); + }); + + it('should leave the file unchanged when re-merging the same configs', async () => { + const source = [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig();', + '', + ].join('\n'); + + const first = await extendEslintConfig(source, ['javascript', 'vitest']); + expect(first).toMatchInlineSnapshot(` + "import { defineConfig } from 'eslint/config'; + + import javascript from '@code-pushup/eslint-config/javascript.js'; + import vitest from '@code-pushup/eslint-config/vitest.js'; + + export default defineConfig(...javascript, ...vitest); + " + `); + await expect( + extendEslintConfig(first, ['javascript', 'vitest']), + ).resolves.toBe(first); + }); + + it('should not duplicate typescript option blocks when re-merging', async () => { + const source = [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig();', + '', + ].join('\n'); + const setup = { typescript: { tsconfigPath: 'tsconfig.json' } }; + + const first = await extendEslintConfig(source, ['typescript'], setup); + expect(first).toMatchInlineSnapshot(` + "import { defineConfig } from 'eslint/config'; + + import typescript from '@code-pushup/eslint-config/typescript.js'; + + export default defineConfig( + ...typescript, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: 'tsconfig.json', + }, + }, + }, + }, + ); + " + `); + await expect( + extendEslintConfig(first, ['typescript'], setup), + ).resolves.toBe(first); + }); + + it('should treat option blocks with the same keys as duplicates regardless of values', async () => { + const source = [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig();', + '', + ].join('\n'); + const setup = { typescript: { tsconfigPath: 'tsconfig.json' } }; + + const initial = await extendEslintConfig(source, ['typescript'], setup); + const userEdited = initial.replace( + "project: 'tsconfig.json'", + "project: 'tsconfig.lib.json'", + ); + + await expect( + extendEslintConfig(userEdited, ['typescript'], setup), + ).resolves.toBe(userEdited); + }); + + it('should preserve user comments outside the inserted regions', async () => { + const source = [ + '// my project rules', + "import { defineConfig } from 'eslint/config';", + '', + '// keep this comment', + 'export default defineConfig(', + ' // entry comment', + " { rules: { 'no-console': 'warn' } },", + ');', + '', + ].join('\n'); + + await expect(extendEslintConfig(source, ['javascript'])).resolves + .toMatchInlineSnapshot(` + "// my project rules + import { defineConfig } from 'eslint/config'; + + import javascript from '@code-pushup/eslint-config/javascript.js'; + + // keep this comment + export default defineConfig( + // entry comment + { rules: { 'no-console': 'warn' } }, + ...javascript, + ); + " + `); + }); + + it('should throw WizardError for export shapes that are not arrays or known helpers', async () => { + const source = ['const config = [];', 'export default config;', ''].join( + '\n', + ); + await expect(extendEslintConfig(source, ['javascript'])).rejects.toThrow( + /only defineConfig/, + ); + await expect( + extendEslintConfig(source, ['javascript']), + ).rejects.toBeInstanceOf(WizardError); + }); + + it('should throw WizardError for syntactically invalid sources', async () => { + await expect( + extendEslintConfig('export default defineConfig(', ['javascript']), + ).rejects.toThrow(/syntax error/); + }); + + it('should reuse an existing aliased import instead of duplicating it', async () => { + const source = [ + "import jsConfig from '@code-pushup/eslint-config/javascript.js';", + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig(', + ' ...jsConfig,', + ');', + '', + ].join('\n'); + + await expect(extendEslintConfig(source, ['javascript'])).resolves + .toMatchInlineSnapshot(` + "import jsConfig from '@code-pushup/eslint-config/javascript.js'; + import { defineConfig } from 'eslint/config'; + + export default defineConfig(...jsConfig); + " + `); + }); + + it('should handle a trailing inline comment when the last entry lacks a comma', async () => { + const source = [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig(', + " { rules: { 'no-console': 'warn' } }", + ' // trailing note', + ');', + '', + ].join('\n'); + + await expect(extendEslintConfig(source, ['javascript'])).resolves + .toMatchInlineSnapshot(` + "import { defineConfig } from 'eslint/config'; + + import javascript from '@code-pushup/eslint-config/javascript.js'; + + export default defineConfig( + // trailing note + { rules: { 'no-console': 'warn' } }, + ...javascript, + ); + " + `); + }); + + it('should preserve CRLF line endings when the source uses CRLF', async () => { + const source = [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig();', + '', + ].join('\r\n'); + + const merged = await extendEslintConfig(source, ['javascript']); + expect(merged).toContain('\r\n'); + expect(merged).not.toMatch(/[^\r]\n/); + }); +}); diff --git a/packages/create-eslint-config/src/lib/codegen/index.ts b/packages/create-eslint-config/src/lib/codegen/index.ts new file mode 100644 index 0000000..c2b8dd3 --- /dev/null +++ b/packages/create-eslint-config/src/lib/codegen/index.ts @@ -0,0 +1,58 @@ +import { WizardError } from '../errors.js'; +import type { CodegenSetup, PackageJson, PeerDep } from '../types.js'; +import { applyEntries, findMergeTarget, parseSource } from './merge-target.js'; +import { detectLineEnding, finalize } from './print.js'; + +const BASE_CONFIG_TEMPLATE = + "import { defineConfig } from 'eslint/config';\nexport default defineConfig();\n"; + +/** Subsumes parent presets, so picking `typescript` drops `javascript`. */ +export async function generateEslintConfig( + slugs: string[], + setup: CodegenSetup = {}, + targetDir?: string, +): Promise { + const file = parseSource(BASE_CONFIG_TEMPLATE); + const target = findMergeTarget(file); + if (!target) { + throw new Error('base config template did not produce a mergeable shape'); + } + applyEntries(target, slugs, setup); + return finalize(file, 'lf', targetDir); +} + +/** Throws WizardError if the export isn't `defineConfig(...)`, `tseslint.config(...)`, or `[...]`. */ +export async function extendEslintConfig( + source: string, + slugs: string[], + setup: CodegenSetup = {}, + targetDir?: string, +): Promise { + const file = parseSource(source); + const target = findMergeTarget(file); + if (!target) { + throw new WizardError( + 'Cannot extend the existing eslint config: only defineConfig(...), tseslint.config(...), and array-literal default exports are supported.', + ); + } + applyEntries(target, slugs, setup); + return finalize(file, detectLineEnding(source), targetDir); +} + +export function extendPackageJson( + current: PackageJson, + deps: PeerDep[], + followUps: CodegenSetup, +): string { + const updated: PackageJson = { + ...current, + devDependencies: { + ...current.devDependencies, + ...Object.fromEntries(deps.map(dep => [dep.name, dep.version])), + }, + }; + if (followUps.node?.source === 'engines') { + updated.engines = { ...current.engines, node: followUps.node.version }; + } + return `${JSON.stringify(updated, null, 2)}\n`; +} diff --git a/packages/create-eslint-config/src/lib/codegen/merge-target.ts b/packages/create-eslint-config/src/lib/codegen/merge-target.ts new file mode 100644 index 0000000..971f69c --- /dev/null +++ b/packages/create-eslint-config/src/lib/codegen/merge-target.ts @@ -0,0 +1,206 @@ +import * as t from '@babel/types'; +import recast from 'recast'; +import babelTsParser from 'recast/parsers/babel-ts.js'; +import { WizardError } from '../errors.js'; +import type { CodegenSetup, ImportDeclarationStructure } from '../types.js'; +import { + buildEntries, + buildImportDeclaration, + buildImports, + type FlatConfigEntry, +} from './builders.js'; + +type HelperPattern = + | { kind: 'function'; module: string; importedName: string } + | { kind: 'method'; module: string; method: string }; + +const SUPPORTED_HELPERS: HelperPattern[] = [ + { kind: 'function', module: 'eslint/config', importedName: 'defineConfig' }, + { kind: 'method', module: 'typescript-eslint', method: 'config' }, +]; + +export type MergeTarget = { + program: t.Program; + hasEntry: (predicate: (existing: t.Node) => boolean) => boolean; + push: (entry: t.SpreadElement | t.ObjectExpression) => void; +}; + +export function parseSource(source: string): t.File { + try { + return recast.parse(source, { parser: babelTsParser }) as t.File; + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + throw new WizardError( + `Cannot extend the existing eslint config: syntax error (${details}).`, + ); + } +} + +/** Returns null if the default export isn't `defineConfig(...)`, `tseslint.config(...)`, or `[...]`. */ +export function findMergeTarget(file: t.File): MergeTarget | null { + const exportDefault = file.program.body.find(t.isExportDefaultDeclaration); + const expression = exportDefault?.declaration; + if (!expression) { + return null; + } + if (t.isArrayExpression(expression)) { + return { + program: file.program, + hasEntry: predicate => + expression.elements.some(e => e != null && predicate(e)), + push: entry => expression.elements.push(entry), + }; + } + if ( + t.isCallExpression(expression) && + isSupportedHelperCall(file, expression) + ) { + return { + program: file.program, + hasEntry: predicate => expression.arguments.some(predicate), + push: entry => expression.arguments.push(entry), + }; + } + return null; +} + +export function applyEntries( + target: MergeTarget, + slugs: string[], + setup: CodegenSetup, +): void { + const existingImports = collectExistingImports(target.program); + const newImports = filterNewImports( + buildImports(slugs, setup), + existingImports, + ); + insertImports(target.program, newImports); + + const newEntries = filterNewEntries( + buildEntries(slugs, setup, existingImports), + target, + ); + newEntries.forEach(entry => target.push(entry.ast)); +} + +function isSupportedHelperCall( + file: t.File, + expression: t.CallExpression, +): boolean { + return SUPPORTED_HELPERS.some(helper => + matchesHelperCall(file, expression, helper), + ); +} + +function matchesHelperCall( + file: t.File, + expression: t.CallExpression, + helper: HelperPattern, +): boolean { + const { callee } = expression; + if (helper.kind === 'function') { + if (!t.isIdentifier(callee)) { + return false; + } + const localName = findLocalImportName( + file.program, + helper.module, + helper.importedName, + ); + return localName != null && callee.name === localName; + } + if ( + !t.isMemberExpression(callee) || + callee.computed || + !t.isIdentifier(callee.object) || + !t.isIdentifier(callee.property) || + callee.property.name !== helper.method + ) { + return false; + } + const localName = findLocalImportName(file.program, helper.module, 'default'); + return localName != null && callee.object.name === localName; +} + +function findLocalImportName( + program: t.Program, + moduleSpecifier: string, + importedName: string, +): string | null { + const declaration = program.body + .filter(t.isImportDeclaration) + .find(decl => decl.source.value === moduleSpecifier); + if (!declaration) { + return null; + } + const specifier = declaration.specifiers.find( + spec => getImportedName(spec) === importedName, + ); + return specifier?.local.name ?? null; +} + +function getImportedName( + specifier: t.ImportDeclaration['specifiers'][number], +): string { + if ( + t.isImportDefaultSpecifier(specifier) || + t.isImportNamespaceSpecifier(specifier) + ) { + return 'default'; + } + const { imported } = specifier; + return t.isIdentifier(imported) ? imported.name : imported.value; +} + +/** Map keyed as `${moduleSpecifier}::${importedName}` to local name. */ +function collectExistingImports(program: t.Program): Map { + return new Map( + program.body + .filter(t.isImportDeclaration) + .flatMap(declaration => + declaration.specifiers.map( + specifier => + [ + `${declaration.source.value}::${getImportedName(specifier)}`, + specifier.local.name, + ] as const, + ), + ), + ); +} + +function filterNewImports( + candidates: ImportDeclarationStructure[], + existingImports: Map, +): ImportDeclarationStructure[] { + return candidates.filter( + ({ moduleSpecifier, defaultImport, namedImports }) => { + const importedName = defaultImport + ? 'default' + : (namedImports?.[0] ?? 'default'); + return !existingImports.has(`${moduleSpecifier}::${importedName}`); + }, + ); +} + +function insertImports( + program: t.Program, + newImports: ImportDeclarationStructure[], +): void { + if (newImports.length === 0) { + return; + } + const declarations = newImports.map(buildImportDeclaration); + const lastImportIdx = program.body.findLastIndex(t.isImportDeclaration); + const insertAt = lastImportIdx === -1 ? 0 : lastImportIdx + 1; + program.body.splice(insertAt, 0, ...declarations); +} + +function filterNewEntries( + candidates: FlatConfigEntry[], + target: MergeTarget, +): FlatConfigEntry[] { + return candidates.filter( + candidate => !target.hasEntry(existing => candidate.matches(existing)), + ); +} diff --git a/packages/create-eslint-config/src/lib/codegen/print.ts b/packages/create-eslint-config/src/lib/codegen/print.ts new file mode 100644 index 0000000..5c4250c --- /dev/null +++ b/packages/create-eslint-config/src/lib/codegen/print.ts @@ -0,0 +1,82 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type * as t from '@babel/types'; +import recast from 'recast'; + +const PRINT_OPTIONS: recast.Options = { + quote: 'single', + tabWidth: 2, + trailingComma: true, +}; + +type LineEnding = 'lf' | 'crlf'; + +type PrettierModule = { + format: (source: string, options: Record) => Promise; + resolveConfig: (path: string) => Promise | null>; +}; + +export function detectLineEnding(source: string): LineEnding { + return source.includes('\r\n') ? 'crlf' : 'lf'; +} + +export async function finalize( + file: t.File, + lineEnding: LineEnding = 'lf', + targetDir?: string, +): Promise { + const printed = stripBlankLinesInsideBlocks( + recast.print(file, PRINT_OPTIONS).code, + ); + const prettier = await loadPrettier(targetDir); + const formatted = prettier + ? await runPrettier(prettier, printed, lineEnding, targetDir) + : ensureTrailingNewline(printed); + return lineEnding === 'crlf' + ? formatted.replace(/\r?\n/g, '\r\n') + : formatted; +} + +async function runPrettier( + prettier: PrettierModule, + source: string, + lineEnding: LineEnding, + targetDir: string | undefined, +): Promise { + const config = await prettier.resolveConfig(targetDir ?? process.cwd()); + return prettier.format(source, { + ...config, + parser: 'babel-ts', + singleQuote: config?.singleQuote ?? true, + trailingComma: config?.trailingComma ?? 'all', + endOfLine: lineEnding, + }); +} + +/** + * Recast occasionally emits a blank line between properties of freshly built + * object literals; collapse blank lines whose preceding line is indented + * (i.e. lives inside a brace or bracket). Top-level blank lines are preserved. + */ +function stripBlankLinesInsideBlocks(source: string): string { + return source.replace(/^([ \t]+[^\n]*)\n[ \t]*\n/gm, '$1\n'); +} + +function ensureTrailingNewline(source: string): string { + return source.endsWith('\n') ? source : `${source}\n`; +} + +async function loadPrettier( + targetDir: string | undefined, +): Promise { + const anchor = targetDir + ? path.join(targetDir, 'package.json') + : import.meta.url; + try { + const require = createRequire(anchor); + const resolved = require.resolve('prettier'); + return (await import(resolved)) as PrettierModule; + } catch { + return null; + } +} diff --git a/packages/create-eslint-config/src/lib/config-registry.spec.ts b/packages/create-eslint-config/src/lib/config-registry.spec.ts index cd56ce7..22b7af9 100644 --- a/packages/create-eslint-config/src/lib/config-registry.spec.ts +++ b/packages/create-eslint-config/src/lib/config-registry.spec.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; import { - ALL_SLUGS, excludeAncestors, - findConfig, + findPreset, includeAncestors, + PRESET_SLUGS, } from './config-registry.js'; -describe('CONFIG_REGISTRY', () => { - it('should list every config slug shipped by the wizard', () => { - expect(ALL_SLUGS).toEqual([ +describe('CONFIG_PRESETS', () => { + it('should list every preset slug shipped by the wizard', () => { + expect(PRESET_SLUGS).toEqual([ 'javascript', 'typescript', 'node', @@ -27,7 +27,7 @@ describe('CONFIG_REGISTRY', () => { it('should recommend javascript unconditionally', () => { expect( - findConfig('javascript')!.isRecommended({ + findPreset('javascript')!.isRecommended({ targetDir: '', packageJson: null, allDeps: new Set(), diff --git a/packages/create-eslint-config/src/lib/config-registry.ts b/packages/create-eslint-config/src/lib/config-registry.ts index 5238e64..826e9a4 100644 --- a/packages/create-eslint-config/src/lib/config-registry.ts +++ b/packages/create-eslint-config/src/lib/config-registry.ts @@ -1,4 +1,4 @@ -import type { ConfigDefinition, PeerDep, ProjectSnapshot } from './types.js'; +import type { ConfigPreset, PeerDep, ProjectSnapshot } from './types.js'; const BACKEND_FRAMEWORKS = [ 'express', @@ -40,18 +40,18 @@ export const BASE_PEER_DEPS: PeerDep[] = [ { name: 'typescript-eslint', version: '^8.0.0' }, ]; -type ConfigMetadata = Omit; - // TODO: add a metadata subpath to code-pushup/eslint-config exporting the slug // list, `extends` fields, and peerDeps (scripts/docs.js already derives these); // import them here. Keep title and isRecommended here. -const CONFIG_METADATA = { - javascript: { +export const CONFIG_PRESETS: ConfigPreset[] = [ + { + slug: 'javascript', title: 'JavaScript (default)', peerDeps: [], isRecommended: () => true, }, - typescript: { + { + slug: 'typescript', title: 'TypeScript (strict)', extends: 'javascript', peerDeps: [ @@ -63,13 +63,15 @@ const CONFIG_METADATA = { isRecommended: snapshot => snapshot.files.has('tsconfig.json') || snapshot.allDeps.has('typescript'), }, - node: { + { + slug: 'node', title: 'Node.js', extends: 'javascript', peerDeps: [{ name: 'eslint-plugin-n', version: '>=17.0.0' }], isRecommended: snapshot => hasAnyDep(snapshot, BACKEND_FRAMEWORKS), }, - angular: { + { + slug: 'angular', title: 'Angular', extends: 'typescript', peerDeps: [ @@ -81,7 +83,8 @@ const CONFIG_METADATA = { ], isRecommended: snapshot => snapshot.allDeps.has('@angular/core'), }, - ngrx: { + { + slug: 'ngrx', title: 'Angular & NgRx', extends: 'angular', peerDeps: [ @@ -92,7 +95,8 @@ const CONFIG_METADATA = { ], isRecommended: snapshot => snapshot.allDeps.has('@ngrx/core'), }, - react: { + { + slug: 'react', title: 'React', extends: 'javascript', peerDeps: [ @@ -102,7 +106,8 @@ const CONFIG_METADATA = { ], isRecommended: snapshot => snapshot.allDeps.has('react'), }, - graphql: { + { + slug: 'graphql', title: 'GraphQL (server)', extends: 'node', peerDeps: [ @@ -113,73 +118,75 @@ const CONFIG_METADATA = { ], isRecommended: snapshot => hasAnyDep(snapshot, GRAPHQL_SERVERS), }, - jest: { + { + slug: 'jest', title: 'Jest', peerDeps: [{ name: 'eslint-plugin-jest', version: '^28.8.0 || ^29.0.0' }], isRecommended: snapshot => snapshot.allDeps.has('jest') || hasAnyFile(snapshot, JEST_CONFIG_PATTERN), }, - vitest: { + { + slug: 'vitest', title: 'Vitest', peerDeps: [{ name: '@vitest/eslint-plugin', version: '^1.1.9' }], isRecommended: snapshot => snapshot.allDeps.has('vitest') || hasAnyFile(snapshot, VITEST_CONFIG_PATTERN), }, - cypress: { + { + slug: 'cypress', title: 'Cypress', peerDeps: [{ name: 'eslint-plugin-cypress', version: '>=3.3.0' }], isRecommended: snapshot => snapshot.allDeps.has('cypress') || hasAnyFile(snapshot, CYPRESS_CONFIG_PATTERN), }, - playwright: { + { + slug: 'playwright', title: 'Playwright', peerDeps: [{ name: 'eslint-plugin-playwright', version: '^2.1.0' }], isRecommended: snapshot => snapshot.allDeps.has('@playwright/test') || snapshot.files.has('playwright.config.ts'), }, - storybook: { + { + slug: 'storybook', title: 'Storybook', peerDeps: [{ name: 'eslint-plugin-storybook', version: '>=0.10.0' }], isRecommended: snapshot => snapshot.allDeps.has('storybook') || snapshot.files.has('.storybook'), }, - 'react-testing-library': { + { + slug: 'react-testing-library', title: 'React Testing Library', peerDeps: [{ name: 'eslint-plugin-testing-library', version: '^7.1.1' }], isRecommended: snapshot => snapshot.allDeps.has('@testing-library/react'), }, -} satisfies Record; - -export const CONFIG_REGISTRY: ConfigDefinition[] = Object.entries( - CONFIG_METADATA, -).map(([slug, meta]) => ({ slug, ...meta })); +]; -export const ALL_SLUGS: string[] = CONFIG_REGISTRY.map(c => c.slug); +export const PRESET_SLUGS: string[] = CONFIG_PRESETS.map(p => p.slug); -export function findConfig(slug: string): ConfigDefinition | undefined { - return CONFIG_REGISTRY.find(c => c.slug === slug); +export function findPreset(slug: string): ConfigPreset | undefined { + return CONFIG_PRESETS.find(p => p.slug === slug); } -export function isConfigSlug(value: string): boolean { - return ALL_SLUGS.includes(value); +export function isPresetSlug(value: string): boolean { + return PRESET_SLUGS.includes(value); } /** Deduplicates and realigns slugs to registry declaration order. */ export function normalizeSlugs(slugs: string[]): string[] { const set = new Set(slugs); - return ALL_SLUGS.filter(slug => set.has(slug)); + return PRESET_SLUGS.filter(slug => set.has(slug)); } -/** Selected slugs minus any that are ancestors of another selected slug. */ +/** Drops any selected slug that is an ancestor of another selected slug. */ export function excludeAncestors(selected: string[]): string[] { const subsumed = new Set(selected.flatMap(collectAncestors)); return normalizeSlugs(selected.filter(slug => !subsumed.has(slug))); } -/** Selected slugs together with all of their ancestors. */ +/** Adds all transitive ancestors to the selection. */ export function includeAncestors(selected: string[]): string[] { return normalizeSlugs( selected.flatMap(slug => [slug, ...collectAncestors(slug)]), @@ -187,6 +194,6 @@ export function includeAncestors(selected: string[]): string[] { } function collectAncestors(slug: string): string[] { - const parent = findConfig(slug)?.extends; + const parent = findPreset(slug)?.extends; return parent ? [parent, ...collectAncestors(parent)] : []; } diff --git a/packages/create-eslint-config/src/lib/detection.spec.ts b/packages/create-eslint-config/src/lib/detection.spec.ts index bcc5a4f..82c0ee2 100644 --- a/packages/create-eslint-config/src/lib/detection.spec.ts +++ b/packages/create-eslint-config/src/lib/detection.spec.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { test } from '../test-setup.js'; import { - collectRecommendedConfigs, + collectRecommendedSlugs, detectExistingEslintConfig, detectNodeVersionInfo, detectTsconfigPath, @@ -21,19 +21,19 @@ const makeSnapshot = ( ...overrides, }); -describe('collectRecommendedConfigs', () => { +describe('collectRecommendedSlugs', () => { it('should recommend only javascript for an empty project', () => { - expect([...collectRecommendedConfigs(makeSnapshot())]).toEqual([ + expect([...collectRecommendedSlugs(makeSnapshot())]).toEqual([ 'javascript', ]); }); - it('should recommend configs matching the project snapshot', () => { + it('should recommend slugs matching the project snapshot', () => { const snapshot = makeSnapshot({ allDeps: new Set(['react', 'vitest']), files: new Set(['tsconfig.json']), }); - const recommended = collectRecommendedConfigs(snapshot); + const recommended = collectRecommendedSlugs(snapshot); expect(recommended).toContain('javascript'); expect(recommended).toContain('typescript'); expect(recommended).toContain('react'); @@ -163,4 +163,24 @@ describe('detectExistingEslintConfig', () => { detectExistingEslintConfig(makeSnapshot()), ).resolves.toBeNull(); }); + + it('should prefer an ESM config over a CJS one when both exist', async () => { + const snapshot = makeSnapshot({ + files: new Set(['eslint.config.cjs', 'eslint.config.mjs']), + }); + await expect(detectExistingEslintConfig(snapshot)).resolves.toMatchObject({ + path: path.join('/test', 'eslint.config.mjs'), + format: 'esm', + }); + }); + + it('should fall back to a CJS config when no ESM config exists', async () => { + const snapshot = makeSnapshot({ + files: new Set(['eslint.config.cjs', 'eslint.config.cts']), + }); + await expect(detectExistingEslintConfig(snapshot)).resolves.toMatchObject({ + path: path.join('/test', 'eslint.config.cjs'), + format: 'cjs', + }); + }); }); diff --git a/packages/create-eslint-config/src/lib/detection.ts b/packages/create-eslint-config/src/lib/detection.ts index 6e7d4ab..1c35db5 100644 --- a/packages/create-eslint-config/src/lib/detection.ts +++ b/packages/create-eslint-config/src/lib/detection.ts @@ -1,6 +1,6 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; -import { CONFIG_REGISTRY } from './config-registry.js'; +import { CONFIG_PRESETS } from './config-registry.js'; import type { ExistingConfig, NodeVersionInfo, @@ -25,11 +25,11 @@ export async function snapshotProject( return { targetDir, packageJson, allDeps, files }; } -export function collectRecommendedConfigs( +export function collectRecommendedSlugs( snapshot: ProjectSnapshot, ): Set { return new Set( - CONFIG_REGISTRY.filter(c => c.isRecommended(snapshot)).map(c => c.slug), + CONFIG_PRESETS.filter(p => p.isRecommended(snapshot)).map(p => p.slug), ); } @@ -68,15 +68,20 @@ async function readNodeVersionFile( export async function detectExistingEslintConfig( snapshot: ProjectSnapshot, ): Promise { - const configFilename = [...snapshot.files].find(name => - ESLINT_CONFIG_PATTERN.test(name), - ); - if (!configFilename) { + const candidates = [...snapshot.files] + .filter(name => ESLINT_CONFIG_PATTERN.test(name)) + .map(name => ({ + name, + format: classifyFormat(name, snapshot.packageJson), + })); + const chosen = + candidates.find(({ format }) => format === 'esm') ?? candidates[0]; + if (!chosen) { return null; } return { - path: path.join(snapshot.targetDir, configFilename), - format: classifyFormat(configFilename, snapshot.packageJson), + path: path.join(snapshot.targetDir, chosen.name), + format: chosen.format, }; } diff --git a/packages/create-eslint-config/src/lib/peer-deps.ts b/packages/create-eslint-config/src/lib/peer-deps.ts index 802981e..427c210 100644 --- a/packages/create-eslint-config/src/lib/peer-deps.ts +++ b/packages/create-eslint-config/src/lib/peer-deps.ts @@ -1,7 +1,7 @@ import { createRequire } from 'node:module'; import { BASE_PEER_DEPS, - findConfig, + findPreset, includeAncestors, } from './config-registry.js'; import type { PeerDep } from './types.js'; @@ -12,7 +12,7 @@ const { version: PACKAGE_VERSION } = createRequire(import.meta.url)( export function resolvePeerDeps(slugs: string[]): PeerDep[] { const configDeps = includeAncestors(slugs).flatMap( - slug => findConfig(slug)?.peerDeps ?? [], + slug => findPreset(slug)?.peerDeps ?? [], ); const all: PeerDep[] = [ ...BASE_PEER_DEPS, diff --git a/packages/create-eslint-config/src/lib/prompts.spec.ts b/packages/create-eslint-config/src/lib/prompts.spec.ts index 5b54acf..75dd9a8 100644 --- a/packages/create-eslint-config/src/lib/prompts.spec.ts +++ b/packages/create-eslint-config/src/lib/prompts.spec.ts @@ -138,8 +138,6 @@ describe('validateConfigSlugs', () => { }); it('should throw on unknown slugs', () => { - expect(() => validateConfigSlugs(['unknown'])).toThrow( - /Failed to resolve config slugs/, - ); + expect(() => validateConfigSlugs(['unknown'])).toThrow(/Unknown configs/); }); }); diff --git a/packages/create-eslint-config/src/lib/prompts.ts b/packages/create-eslint-config/src/lib/prompts.ts index 8ad90d3..171831c 100644 --- a/packages/create-eslint-config/src/lib/prompts.ts +++ b/packages/create-eslint-config/src/lib/prompts.ts @@ -1,13 +1,13 @@ import { checkbox, input as inputPrompt, select } from '@inquirer/prompts'; import semver from 'semver'; import { - ALL_SLUGS, - CONFIG_REGISTRY, - isConfigSlug, + CONFIG_PRESETS, + isPresetSlug, normalizeSlugs, + PRESET_SLUGS, } from './config-registry.js'; import { - collectRecommendedConfigs, + collectRecommendedSlugs, detectNodeVersionInfo, detectTsconfigPath, } from './detection.js'; @@ -30,17 +30,17 @@ export async function promptConfigSelection( if (options.configs && options.configs.length > 0) { return normalizeSlugs(options.configs); } - const recommended = collectRecommendedConfigs(snapshot); + const recommended = collectRecommendedSlugs(snapshot); if (options.yes) { return normalizeSlugs([...recommended]); } const selected = await checkbox({ message: 'Configurations to set up:', required: true, - choices: CONFIG_REGISTRY.map(config => ({ - name: config.title, - value: config.slug, - checked: recommended.has(config.slug), + choices: CONFIG_PRESETS.map(preset => ({ + name: preset.title, + value: preset.slug, + checked: recommended.has(preset.slug), })), }); return normalizeSlugs(selected); @@ -132,12 +132,11 @@ export async function collectFollowUps( } export function validateConfigSlugs(slugs: string[]): string[] { - const valid = slugs.filter(isConfigSlug); - if (valid.length < slugs.length) { - const invalid = slugs.filter(slug => !isConfigSlug(slug)); + const invalid = slugs.filter(slug => !isPresetSlug(slug)); + if (invalid.length > 0) { throw new WizardError( - `Failed to resolve config slugs: unknown ${invalid.join(', ')}. Available: ${ALL_SLUGS.join(', ')}.`, + `Unknown configs: ${invalid.join(', ')}. Available: ${PRESET_SLUGS.join(', ')}.`, ); } - return valid; + return slugs; } diff --git a/packages/create-eslint-config/src/lib/types.ts b/packages/create-eslint-config/src/lib/types.ts index 0710296..e20a3bf 100644 --- a/packages/create-eslint-config/src/lib/types.ts +++ b/packages/create-eslint-config/src/lib/types.ts @@ -22,6 +22,10 @@ export type FileChange = { content: string; }; +export type PendingEntry = Omit & { + original: string | null; +}; + export const NODE_VERSION_SOURCES = [ 'node-version', 'engines', @@ -53,7 +57,12 @@ export type ExistingConfig = { format: 'esm' | 'cjs'; }; -export type ConfigDefinition = { +export type LoadedEslintConfig = { + source: string; + relativePath: string; +}; + +export type ConfigPreset = { slug: string; title: string; extends?: string; @@ -89,6 +98,7 @@ export type FileSystemAdapter = { path: string, options: { recursive: true }, ) => Promise; + unlink: (path: string) => Promise; }; export type Tree = { @@ -109,17 +119,8 @@ export type WizardOptions = { yes?: boolean; }; -/** - * Return shape of `runSetupWizard`. `files` paths are relative to `root`; - * call `flush()` to persist all pending changes to disk. - * - * TODO: once the wizard can merge into existing configs, drop - * `manualSnippet` and `manualSnippetPath` and collapse this type. - */ export type WizardResult = { root: string; files: FileChange[]; flush: () => Promise; - manualSnippet?: string; - manualSnippetPath?: string; }; diff --git a/packages/create-eslint-config/src/lib/virtual-fs.spec.ts b/packages/create-eslint-config/src/lib/virtual-fs.spec.ts index 9f5444f..b1754a3 100644 --- a/packages/create-eslint-config/src/lib/virtual-fs.spec.ts +++ b/packages/create-eslint-config/src/lib/virtual-fs.spec.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { describe, expect } from 'vitest'; import { test } from '../test-setup.js'; @@ -72,4 +72,40 @@ describe('createTree', () => { expect(tree.listChanges()).toEqual([]); }); + + test('should roll back a partial flush when one write fails', async ({ + tmp, + }) => { + const existingPath = path.join(tmp, 'existing.txt'); + const createdPath = path.join(tmp, 'created.txt'); + await writeFile(existingPath, 'original'); + + const tree = createTree(tmp, { + readFile, + writeFile: async (filePath, content) => { + if (filePath.endsWith('boom.txt')) { + throw new Error('disk full'); + } + await writeFile(filePath, content); + }, + exists: filePath => + readFile(filePath, 'utf8').then( + () => true, + () => false, + ), + mkdir: async () => 'created', + unlink, + }); + + await tree.write('existing.txt', 'updated'); + await tree.write('created.txt', 'new'); + await tree.write('boom.txt', 'never'); + + await expect(tree.flush()).rejects.toThrow(/disk full/); + + await expect(readFile(existingPath, 'utf8')).resolves.toBe('original'); + await expect(readFile(createdPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); }); diff --git a/packages/create-eslint-config/src/lib/virtual-fs.ts b/packages/create-eslint-config/src/lib/virtual-fs.ts index 3b7d0d8..80280e8 100644 --- a/packages/create-eslint-config/src/lib/virtual-fs.ts +++ b/packages/create-eslint-config/src/lib/virtual-fs.ts @@ -1,6 +1,11 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import type { FileChange, FileSystemAdapter, Tree } from './types.js'; +import type { + FileChange, + FileSystemAdapter, + PendingEntry, + Tree, +} from './types.js'; import { fileExists } from './utils.js'; const DEFAULT_FS: FileSystemAdapter = { @@ -8,6 +13,7 @@ const DEFAULT_FS: FileSystemAdapter = { writeFile, exists: fileExists, mkdir, + unlink, }; // eslint-disable-next-line max-lines-per-function @@ -15,7 +21,7 @@ export function createTree( root: string, fs: FileSystemAdapter = DEFAULT_FS, ): Tree { - const pending = new Map>(); + const pending = new Map(); const resolve = (filePath: string): string => path.resolve(root, filePath); return { @@ -50,6 +56,7 @@ export function createTree( pending.set(filePath, { content, type: existing == null ? 'CREATE' : 'UPDATE', + original: existing, }); } }, @@ -62,14 +69,46 @@ export function createTree( })), async flush(): Promise { - await Promise.all( - [...pending.entries()].map(async ([filePath, { content }]) => { - const absolutePath = resolve(filePath); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, content); - }), - ); - pending.clear(); + const written = new Set(); + try { + await [...pending.entries()].reduce>( + (acc, [filePath, { content }]) => + acc.then(async () => { + const absolutePath = resolve(filePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content); + written.add(filePath); + return null; + }), + Promise.resolve(null), + ); + pending.clear(); + } catch (error) { + await rollback([...written], pending, fs, resolve); + throw error; + } }, }; } + +async function rollback( + written: string[], + pending: Map, + fs: FileSystemAdapter, + resolve: (filePath: string) => string, +): Promise { + await Promise.allSettled( + written.map(async filePath => { + const entry = pending.get(filePath); + if (!entry) { + return; + } + const absolutePath = resolve(filePath); + if (entry.original == null) { + await fs.unlink(absolutePath); + return; + } + await fs.writeFile(absolutePath, entry.original); + }), + ); +} diff --git a/packages/create-eslint-config/src/lib/wizard.spec.ts b/packages/create-eslint-config/src/lib/wizard.spec.ts index 5213d08..27aaffe 100644 --- a/packages/create-eslint-config/src/lib/wizard.spec.ts +++ b/packages/create-eslint-config/src/lib/wizard.spec.ts @@ -1,6 +1,6 @@ -import { writeFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { beforeEach, describe, expect, vi } from 'vitest'; +import { assert, beforeEach, describe, expect, vi } from 'vitest'; import { test } from '../test-setup.js'; import type { FileChange } from './types.js'; import { runSetupWizard } from './wizard.js'; @@ -13,15 +13,17 @@ const { checkbox, input, select } = vi.hoisted(() => ({ vi.mock('@inquirer/prompts', () => ({ checkbox, input, select })); -function findChange(files: FileChange[], name: string): FileChange | undefined { - return files.find(f => f.path.endsWith(name)); +function findChange(files: FileChange[], name: string): FileChange { + const found = files.find(f => f.path.endsWith(name)); + assert(found, `expected file change ending with ${name}`); + return found; } function parseJson>( files: FileChange[], name: string, ): T { - return JSON.parse(findChange(files, name)?.content ?? '{}') as T; + return JSON.parse(findChange(files, name).content) as T; } describe('runSetupWizard', () => { @@ -34,14 +36,17 @@ describe('runSetupWizard', () => { test('should create eslint.config.mjs when type:module is not set', async ({ tmp, }) => { - const { files, manualSnippet } = await runSetupWizard({ + const { files } = await runSetupWizard({ targetDir: tmp, yes: true, }); - const config = findChange(files, 'eslint.config.mjs'); - expect(config?.type).toBe('CREATE'); - expect(config?.content).toContain('defineConfig('); - expect(manualSnippet).toBeUndefined(); + expect(files).toContainEqual( + expect.objectContaining({ + path: 'eslint.config.mjs', + type: 'CREATE', + content: expect.stringContaining('defineConfig('), + }), + ); }); test('should create eslint.config.js when type:module is set', async ({ @@ -52,19 +57,27 @@ describe('runSetupWizard', () => { JSON.stringify({ type: 'module' }), ); const { files } = await runSetupWizard({ targetDir: tmp, yes: true }); - expect(findChange(files, 'eslint.config.js')).toBeDefined(); - expect(findChange(files, 'eslint.config.mjs')).toBeUndefined(); + expect(files).toContainEqual( + expect.objectContaining({ path: 'eslint.config.js' }), + ); + expect(files).not.toContainEqual( + expect.objectContaining({ path: 'eslint.config.mjs' }), + ); }); test('should add deps to package.json', async ({ tmp }) => { const { files } = await runSetupWizard({ targetDir: tmp, yes: true }); - expect(findChange(files, 'package.json')?.type).toBe('CREATE'); - const pkg = parseJson<{ devDependencies?: Record }>( + expect(files).toContainEqual( + expect.objectContaining({ path: 'package.json', type: 'CREATE' }), + ); + const packageJson = parseJson<{ devDependencies: Record }>( files, 'package.json', ); - expect(pkg.devDependencies?.eslint).toBeDefined(); - expect(pkg.devDependencies?.['@code-pushup/eslint-config']).toBeDefined(); + expect(packageJson.devDependencies.eslint).toBeDefined(); + expect( + packageJson.devDependencies['@code-pushup/eslint-config'], + ).toBeDefined(); }); test('should update existing package.json while preserving other fields', async ({ @@ -79,33 +92,78 @@ describe('runSetupWizard', () => { }), ); const { files } = await runSetupWizard({ targetDir: tmp, yes: true }); - expect(findChange(files, 'package.json')?.type).toBe('UPDATE'); - const pkg = parseJson<{ + expect(files).toContainEqual( + expect.objectContaining({ path: 'package.json', type: 'UPDATE' }), + ); + const packageJson = parseJson<{ name: string; scripts: Record; devDependencies: Record; }>(files, 'package.json'); - expect(pkg.name).toBe('demo'); - expect(pkg.scripts).toEqual({ test: 'vitest' }); - expect(pkg.devDependencies.vitest).toBe('1.0.0'); - expect(pkg.devDependencies.eslint).toBeDefined(); + expect(packageJson.name).toBe('demo'); + expect(packageJson.scripts).toEqual({ test: 'vitest' }); + expect(packageJson.devDependencies.vitest).toBe('1.0.0'); + expect(packageJson.devDependencies.eslint).toBeDefined(); }); - test('should return a snippet when an ESM config already exists', async ({ + test('should not modify the eslint config when re-running with the same configs', async ({ tmp, }) => { await writeFile( path.join(tmp, 'package.json'), JSON.stringify({ type: 'module' }), ); - await writeFile(path.join(tmp, 'eslint.config.js'), 'export default [];'); - const { files, manualSnippet, manualSnippetPath } = await runSetupWizard({ + await writeFile( + path.join(tmp, 'eslint.config.js'), + [ + "import { defineConfig } from 'eslint/config';", + '', + 'export default defineConfig();', + '', + ].join('\n'), + ); + + const first = await runSetupWizard({ targetDir: tmp, + configs: ['javascript'], yes: true, }); - expect(findChange(files, 'eslint.config.js')).toBeUndefined(); - expect(manualSnippet).toContain('defineConfig'); - expect(manualSnippetPath).toMatch(/eslint\.config\.js$/); + await first.flush(); + + const second = await runSetupWizard({ + targetDir: tmp, + configs: ['javascript'], + yes: true, + }); + + expect(second.files).not.toContainEqual( + expect.objectContaining({ path: 'eslint.config.js' }), + ); + }); + + test('should throw and leave package.json untouched when the existing config has an unsupported shape', async ({ + tmp, + }) => { + await writeFile( + path.join(tmp, 'package.json'), + JSON.stringify({ type: 'module' }), + ); + await writeFile( + path.join(tmp, 'eslint.config.js'), + ['const config = [];', 'export default config;', ''].join('\n'), + ); + const before = await readFile(path.join(tmp, 'package.json'), 'utf8'); + + await expect( + runSetupWizard({ + targetDir: tmp, + configs: ['javascript'], + yes: true, + }), + ).rejects.toThrow(/defineConfig/); + + const after = await readFile(path.join(tmp, 'package.json'), 'utf8'); + expect(after).toBe(before); }); test('should throw when a CJS config is detected', async ({ tmp }) => { @@ -124,9 +182,11 @@ describe('runSetupWizard', () => { nodeVersionSource: 'node-version', nodeVersion: '>=22.0.0', }); - const nv = findChange(files, '.node-version'); - expect(nv?.type).toBe('CREATE'); - expect(nv?.content).toBe('>=22.0.0\n'); + expect(files).toContainEqual({ + path: '.node-version', + type: 'CREATE', + content: '>=22.0.0\n', + }); }); test('should set engines.node when source is engines', async ({ tmp }) => { @@ -136,11 +196,11 @@ describe('runSetupWizard', () => { nodeVersionSource: 'engines', nodeVersion: '>=20.0.0', }); - const pkg = parseJson<{ engines?: { node: string } }>( + const packageJson = parseJson<{ engines: { node: string } }>( files, 'package.json', ); - expect(pkg.engines?.node).toBe('>=20.0.0'); + expect(packageJson.engines.node).toBe('>=20.0.0'); }); test('should include config-specific deps', async ({ tmp }) => { @@ -148,10 +208,10 @@ describe('runSetupWizard', () => { targetDir: tmp, configs: ['javascript', 'react'], }); - const pkg = parseJson<{ devDependencies?: Record }>( + const packageJson = parseJson<{ devDependencies: Record }>( files, 'package.json', ); - expect(pkg.devDependencies?.['eslint-plugin-react']).toBeDefined(); + expect(packageJson.devDependencies['eslint-plugin-react']).toBeDefined(); }); }); diff --git a/packages/create-eslint-config/src/lib/wizard.ts b/packages/create-eslint-config/src/lib/wizard.ts index b1fe326..1dcd501 100644 --- a/packages/create-eslint-config/src/lib/wizard.ts +++ b/packages/create-eslint-config/src/lib/wizard.ts @@ -1,66 +1,97 @@ import path from 'node:path'; import { - generateEslintConfigSnippet, - generateEslintConfigSource, - generatePackageJson, -} from './codegen.js'; + generateEslintConfig, + extendPackageJson, + extendEslintConfig, +} from './codegen/index.js'; import { detectExistingEslintConfig, snapshotProject } from './detection.js'; import { WizardError } from './errors.js'; import { resolvePeerDeps } from './peer-deps.js'; -import { collectFollowUps, promptConfigSelection } from './prompts.js'; -import type { PackageJson, WizardOptions, WizardResult } from './types.js'; +import { + collectFollowUps, + promptConfigSelection, + validateConfigSlugs, +} from './prompts.js'; +import type { + LoadedEslintConfig, + ProjectSnapshot, + Tree, + WizardOptions, + WizardResult, +} from './types.js'; import { isProjectEsm } from './utils.js'; import { createTree } from './virtual-fs.js'; export async function runSetupWizard( options: WizardOptions, ): Promise { + if (options.configs) { + validateConfigSlugs(options.configs); + } const targetDir = path.resolve(options.targetDir); const normalized = { ...options, targetDir }; const snapshot = await snapshotProject(targetDir); - - const existingConfig = await detectExistingEslintConfig(snapshot); - if (existingConfig?.format === 'cjs') { - throw new WizardError( - 'Failed to extend existing eslint config: only ESM format is supported.', - ); - } + const tree = createTree(targetDir); + const existingConfig = await loadExistingEslintConfig(tree, snapshot); const configs = await promptConfigSelection(normalized, snapshot); const followUps = await collectFollowUps(configs, normalized, snapshot); - const deps = resolvePeerDeps(configs); + const eslintConfigPath = existingConfig + ? existingConfig.relativePath + : isProjectEsm(snapshot.packageJson) + ? 'eslint.config.js' + : 'eslint.config.mjs'; + + const eslintConfigSource = existingConfig + ? await extendEslintConfig( + existingConfig.source, + configs, + followUps, + targetDir, + ) + : await generateEslintConfig(configs, followUps, targetDir); - const tree = createTree(targetDir); await tree.write( 'package.json', - generatePackageJson( - JSON.parse((await tree.read('package.json')) ?? '{}') as PackageJson, - deps, + extendPackageJson( + snapshot.packageJson ?? {}, + resolvePeerDeps(configs), followUps, ), ); if (followUps.node?.source === 'node-version') { await tree.write('.node-version', `${followUps.node.version}\n`); } - if (!existingConfig) { - await tree.write( - isProjectEsm(snapshot.packageJson) - ? 'eslint.config.js' - : 'eslint.config.mjs', - generateEslintConfigSource(configs, followUps), - ); - } + await tree.write(eslintConfigPath, eslintConfigSource); - // TODO: merge into existing config instead of returning a snippet return { root: tree.root, files: tree.listChanges(), flush: () => tree.flush(), - ...(existingConfig && { - manualSnippet: generateEslintConfigSnippet(configs, followUps), - manualSnippetPath: existingConfig.path, - }), }; } + +async function loadExistingEslintConfig( + tree: Tree, + snapshot: ProjectSnapshot, +): Promise { + const detected = await detectExistingEslintConfig(snapshot); + if (detected == null) { + return null; + } + if (detected.format === 'cjs') { + throw new WizardError( + 'Failed to extend existing eslint config: only ESM format is supported.', + ); + } + const relativePath = path.relative(tree.root, detected.path); + const source = await tree.read(relativePath); + if (source == null) { + throw new WizardError( + `Failed to read existing eslint config at ${relativePath}.`, + ); + } + return { source, relativePath }; +}