diff --git a/eslint.config.js b/eslint.config.js index a28169dbc..2ee24a40d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,7 +45,12 @@ export default tseslint.config( }, { sourceTag: 'scope:tooling', - onlyDependOnLibsWithTags: ['scope:tooling', 'scope:shared'], + onlyDependOnLibsWithTags: [ + 'scope:tooling', + 'scope:core', + 'scope:plugin', + 'scope:shared', + ], }, { sourceTag: 'type:e2e', diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 1d105b7a3..8b3d2fed5 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -25,6 +25,18 @@ The wizard will prompt you to select plugins and configure their options, then g | **`--dry-run`** | `boolean` | `false` | Preview changes without writing files | | **`--yes`**, `-y` | `boolean` | `false` | Skip prompts and use defaults | +### Plugin options + +Each plugin exposes its own configuration keys that can be passed as CLI arguments to skip the corresponding prompts. + +#### ESLint + +| Option | Type | Default | Description | +| ------------------------- | --------- | ------------- | -------------------------- | +| **`--eslint.eslintrc`** | `string` | auto-detected | Path to ESLint config | +| **`--eslint.patterns`** | `string` | `src` or `.` | File patterns to lint | +| **`--eslint.categories`** | `boolean` | `true` | Add recommended categories | + ### Examples Run interactively (default): diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index ef26cda93..0c949adf9 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -26,6 +26,7 @@ }, "type": "module", "dependencies": { + "@code-pushup/eslint-plugin": "0.119.1", "@code-pushup/models": "0.119.1", "@code-pushup/utils": "0.119.1", "@inquirer/prompts": "^8.0.0", diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index f54aec268..160d2b2dc 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,6 +1,7 @@ #! /usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { eslintSetupBinding } from '@code-pushup/eslint-plugin'; import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { CI_PROVIDERS, @@ -10,8 +11,8 @@ import { } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; -// TODO: create, import and pass plugin bindings (eslint, coverage, lighthouse, typescript, js-packages, jsdocs, axe) -const bindings: PluginSetupBinding[] = []; +// TODO: create, import and pass remaining plugin bindings (coverage, lighthouse, typescript, js-packages, jsdocs, axe) +const bindings: PluginSetupBinding[] = [eslintSetupBinding]; const argv = await yargs(hideBin(process.argv)) .option('dry-run', { diff --git a/packages/create-cli/src/lib/setup/prompts.ts b/packages/create-cli/src/lib/setup/prompts.ts index 7811ada06..42895daf5 100644 --- a/packages/create-cli/src/lib/setup/prompts.ts +++ b/packages/create-cli/src/lib/setup/prompts.ts @@ -1,7 +1,8 @@ -import { checkbox, input, select } from '@inquirer/prompts'; +import { checkbox, confirm, input, select } from '@inquirer/prompts'; import { asyncSequential } from '@code-pushup/utils'; import type { CliArgs, + PluginAnswer, PluginPromptDescriptor, PluginSetupBinding, } from './types.js'; @@ -64,7 +65,7 @@ async function detectRecommended( export async function promptPluginOptions( descriptors: PluginPromptDescriptor[], cliArgs: CliArgs, -): Promise> { +): Promise> { const fallback = cliArgs['yes'] ? (descriptor: PluginPromptDescriptor) => descriptor.default : runPrompt; @@ -76,14 +77,17 @@ export async function promptPluginOptions( return Object.fromEntries(entries); } -function cliValue(key: string, cliArgs: CliArgs): string | undefined { +function cliValue(key: string, cliArgs: CliArgs): PluginAnswer | undefined { const value = cliArgs[key]; - return typeof value === 'string' ? value : undefined; + if (typeof value === 'string' || typeof value === 'boolean') { + return value; + } + return undefined; } async function runPrompt( descriptor: PluginPromptDescriptor, -): Promise { +): Promise { switch (descriptor.type) { case 'input': return input({ @@ -100,5 +104,10 @@ async function runPrompt( message: descriptor.message, choices: [...descriptor.choices], }); + case 'confirm': + return confirm({ + message: descriptor.message, + default: descriptor.default, + }); } } diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index aaca446b0..fbc04c49e 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,6 +1,14 @@ -import type { CategoryConfig, PluginMeta } from '@code-pushup/models'; +import type { PluginCodegenResult } from '@code-pushup/models'; import type { MonorepoTool } from '@code-pushup/utils'; +export type { + ImportDeclarationStructure, + PluginAnswer, + PluginCodegenResult, + PluginPromptDescriptor, + PluginSetupBinding, +} from '@code-pushup/models'; + export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const; export type CiProvider = (typeof CI_PROVIDERS)[number]; @@ -24,50 +32,6 @@ export type CliArgs = { [key: string]: unknown; }; -type PromptBase = { - key: string; - message: string; -}; - -type PromptChoice = { name: string; value: T }; - -type InputPrompt = PromptBase & { - type: 'input'; - default: string; -}; - -type SelectPrompt = PromptBase & { - type: 'select'; - choices: PromptChoice[]; - default: T; -}; - -type CheckboxPrompt = PromptBase & { - type: 'checkbox'; - choices: PromptChoice[]; - default: T[]; -}; - -/** Declarative prompt definition used to collect plugin-specific options. */ -export type PluginPromptDescriptor = - | InputPrompt - | SelectPrompt - | CheckboxPrompt; - -export type ImportDeclarationStructure = { - moduleSpecifier: string; - defaultImport?: string; - namedImports?: string[]; - isTypeOnly?: boolean; -}; - -/** Import declarations and plugin initialization code produced by `generateConfig`. */ -export type PluginCodegenResult = { - imports: ImportDeclarationStructure[]; - pluginInit: string; - categories?: CategoryConfig[]; -}; - export type ScopedPluginResult = { scope: PluginScope; result: PluginCodegenResult; @@ -79,27 +43,6 @@ export type ConfigContext = { tool: MonorepoTool | null; }; -/** - * Defines how a plugin integrates with the setup wizard. - * - * Each supported plugin provides a binding that controls: - * - Pre-selection: `isRecommended` detects if the plugin is relevant for the repository - * - Configuration: `prompts` collect plugin-specific options interactively - * - Code generation: `generateConfig` produces the import and initialization code - */ -export type PluginSetupBinding = { - slug: PluginMeta['slug']; - title: PluginMeta['title']; - packageName: NonNullable; - prompts?: PluginPromptDescriptor[]; - scope?: PluginScope; - isRecommended?: (targetDir: string) => Promise; - generateConfig: ( - answers: Record, - context: ConfigContext, - ) => PluginCodegenResult; -}; - /** A project discovered in a monorepo workspace. */ export type WizardProject = { name: string; diff --git a/packages/create-cli/src/lib/setup/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts index b434e86e0..db8116028 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { eslintSetupBinding } from '@code-pushup/eslint-plugin'; import { cleanTestFolder } from '@code-pushup/test-utils'; import { getGitRoot } from '@code-pushup/utils'; import type { PluginSetupBinding } from './types.js'; @@ -19,7 +20,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ title: 'Alpha Plugin', packageName: '@code-pushup/alpha-plugin', isRecommended: () => Promise.resolve(true), - prompts: [ + prompts: async () => [ { key: 'alpha.path', message: 'Path to config', @@ -222,4 +223,71 @@ describe('runSetupWizard', () => { readFile(path.join(outputDir, '.gitignore'), 'utf8'), ).resolves.toBe('node_modules\n.code-pushup\n'); }); + + it('should generate config with ESLint plugin using defaults', async () => { + await runSetupWizard([eslintSetupBinding], { + yes: true, + plugins: ['eslint'], + 'config-format': 'ts', + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), + ).resolves.toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + await eslintPlugin(), + ], + categories: [ + { + slug: 'bug-prevention', + title: 'Bug prevention', + description: 'Lint rules that find **potential bugs** in your code.', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + ], + }, + { + slug: 'code-style', + title: 'Code style', + description: 'Lint rules that promote **good practices** and consistency in your code.', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }, + ], + }, + ], + } satisfies CoreConfig; + " + `); + }); + + it('should generate config with custom ESLint options', async () => { + await runSetupWizard([eslintSetupBinding], { + yes: true, + plugins: ['eslint'], + 'config-format': 'ts', + 'target-dir': outputDir, + 'eslint.eslintrc': 'custom-eslint.config.js', + 'eslint.patterns': 'src', + 'eslint.categories': false, + }); + + await expect( + readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), + ).resolves.toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + await eslintPlugin({ eslintrc: 'custom-eslint.config.js', patterns: 'src' }), + ], + } satisfies CoreConfig; + " + `); + }); }); diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index cde4c6972..a13e8819a 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -28,7 +28,6 @@ import { import { promptPluginOptions, promptPluginSelection } from './prompts.js'; import type { CliArgs, - ConfigContext, FileChange, PluginCodegenResult, PluginSetupBinding, @@ -62,7 +61,7 @@ export async function runSetupWizard( selectedBindings, async binding => ({ scope: binding.scope ?? 'project', - result: await resolveBinding(binding, cliArgs, context), + result: await resolveBinding(binding, cliArgs, targetDir), }), ); @@ -103,12 +102,14 @@ export async function runSetupWizard( async function resolveBinding( binding: PluginSetupBinding, cliArgs: CliArgs, - context: ConfigContext, + targetDir: string, ): Promise { - const answers = binding.prompts - ? await promptPluginOptions(binding.prompts, cliArgs) - : {}; - return binding.generateConfig(answers, context); + const descriptors = binding.prompts ? await binding.prompts(targetDir) : []; + const answers = + descriptors.length > 0 + ? await promptPluginOptions(descriptors, cliArgs) + : {}; + return binding.generateConfig(answers); } async function writeStandaloneConfig( diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 217657763..b12a5d344 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -112,6 +112,13 @@ export { type PluginScoreTargets, type PluginUrls, } from './lib/plugin-config.js'; +export type { + ImportDeclarationStructure, + PluginAnswer, + PluginCodegenResult, + PluginPromptDescriptor, + PluginSetupBinding, +} from './lib/plugin-setup.js'; export { auditReportSchema, pluginReportSchema, diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts new file mode 100644 index 000000000..1b684016e --- /dev/null +++ b/packages/models/src/lib/plugin-setup.ts @@ -0,0 +1,75 @@ +import type { CategoryConfig } from './category-config.js'; +import type { PluginMeta } from './plugin-config.js'; + +type PromptBase = { + key: string; + message: string; +}; + +type PromptChoice = { name: string; value: T }; + +type InputPrompt = PromptBase & { + type: 'input'; + default: string; +}; + +type SelectPrompt = PromptBase & { + type: 'select'; + choices: PromptChoice[]; + default: T; +}; + +type CheckboxPrompt = PromptBase & { + type: 'checkbox'; + choices: PromptChoice[]; + default: T[]; +}; + +type ConfirmPrompt = PromptBase & { + type: 'confirm'; + default: boolean; +}; + +/** Declarative prompt definition used to collect plugin-specific options. */ +export type PluginPromptDescriptor = + | InputPrompt + | SelectPrompt + | CheckboxPrompt + | ConfirmPrompt; + +export type ImportDeclarationStructure = { + moduleSpecifier: string; + defaultImport?: string; + namedImports?: string[]; + isTypeOnly?: boolean; +}; + +/** A single value in the answers record produced by plugin prompts. */ +export type PluginAnswer = string | string[] | boolean; + +/** Import declarations and plugin initialization code produced by `generateConfig`. */ +export type PluginCodegenResult = { + imports: ImportDeclarationStructure[]; + pluginInit: string; + categories?: CategoryConfig[]; +}; + +/** + * Defines how a plugin integrates with the setup wizard. + * + * Each supported plugin provides a binding that controls: + * - Pre-selection: `isRecommended` detects if the plugin is relevant for the repository + * - Configuration: `prompts` collect plugin-specific options interactively + * - Code generation: `generateConfig` produces the import and initialization code + */ +export type PluginSetupBinding = { + slug: PluginMeta['slug']; + title: PluginMeta['title']; + packageName: NonNullable; + scope?: 'project' | 'root'; + prompts?: (targetDir: string) => Promise; + isRecommended?: (targetDir: string) => Promise; + generateConfig: ( + answers: Record, + ) => PluginCodegenResult; +}; diff --git a/packages/plugin-eslint/src/index.ts b/packages/plugin-eslint/src/index.ts index 3d2ca810d..2b5188700 100644 --- a/packages/plugin-eslint/src/index.ts +++ b/packages/plugin-eslint/src/index.ts @@ -2,6 +2,7 @@ import { eslintPlugin } from './lib/eslint-plugin.js'; export default eslintPlugin; +export { eslintSetupBinding } from './lib/binding.js'; export type { ESLintPluginConfig } from './lib/config.js'; export { ESLINT_PLUGIN_SLUG } from './lib/constants.js'; diff --git a/packages/plugin-eslint/src/lib/binding.ts b/packages/plugin-eslint/src/lib/binding.ts new file mode 100644 index 000000000..fcddc2de5 --- /dev/null +++ b/packages/plugin-eslint/src/lib/binding.ts @@ -0,0 +1,156 @@ +import { readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { + CategoryConfig, + PluginAnswer, + PluginSetupBinding, +} from '@code-pushup/models'; +import { + directoryExists, + hasDependency, + readJsonFile, + singleQuote, +} from '@code-pushup/utils'; +import { + DEFAULT_PATTERN, + ESLINT_PLUGIN_SLUG, + ESLINT_PLUGIN_TITLE, +} from './constants.js'; + +const { name: PACKAGE_NAME } = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +const ESLINT_CONFIG_PATTERN = /^(\.eslintrc(\.\w+)?|eslint\.config\.\w+)$/; + +const ESLINT_CATEGORIES: CategoryConfig[] = [ + { + slug: 'bug-prevention', + title: 'Bug prevention', + description: 'Lint rules that find **potential bugs** in your code.', + refs: [ + { + type: 'group', + plugin: ESLINT_PLUGIN_SLUG, + slug: 'problems', + weight: 1, + }, + ], + }, + { + slug: 'code-style', + title: 'Code style', + description: + 'Lint rules that promote **good practices** and consistency in your code.', + refs: [ + { + type: 'group', + plugin: ESLINT_PLUGIN_SLUG, + slug: 'suggestions', + weight: 1, + }, + ], + }, +]; + +export const eslintSetupBinding = { + slug: ESLINT_PLUGIN_SLUG, + title: ESLINT_PLUGIN_TITLE, + packageName: PACKAGE_NAME, + isRecommended, + prompts: async (targetDir: string) => [ + { + key: 'eslint.eslintrc', + message: 'Path to ESLint config', + type: 'input', + default: (await detectEslintConfig(targetDir)) ?? '', + }, + { + key: 'eslint.patterns', + message: 'File patterns to lint', + type: 'input', + default: (await directoryExists(path.join(targetDir, 'src'))) + ? 'src' + : DEFAULT_PATTERN, + }, + { + key: 'eslint.categories', + message: 'Add recommended categories (bug prevention, code style)?', + type: 'confirm', + default: true, + }, + ], + generateConfig: (answers: Record) => { + const withCategories = answers['eslint.categories'] !== false; + const args = [ + resolveEslintrc(answers['eslint.eslintrc']), + resolvePatterns(answers['eslint.patterns']), + ].filter(Boolean); + + return { + imports: [ + { moduleSpecifier: PACKAGE_NAME, defaultImport: 'eslintPlugin' }, + ], + pluginInit: + args.length > 0 + ? `await eslintPlugin({ ${args.join(', ')} })` + : 'await eslintPlugin()', + ...(withCategories ? { categories: ESLINT_CATEGORIES } : {}), + }; + }, +} satisfies PluginSetupBinding; + +async function detectEslintConfig( + targetDir: string, +): Promise { + const files = await readdir(targetDir, { encoding: 'utf8' }); + return files.find(file => ESLINT_CONFIG_PATTERN.test(file)); +} + +async function isRecommended(targetDir: string): Promise { + if (await detectEslintConfig(targetDir)) { + return true; + } + try { + const packageJson = await readJsonFile<{ + dependencies?: Record; + devDependencies?: Record; + }>(path.join(targetDir, 'package.json')); + return hasDependency(packageJson, 'eslint'); + } catch { + return false; + } +} + +/** Omits `eslintrc` for standard config filenames (ESLint discovers them automatically). */ +function resolveEslintrc(value: PluginAnswer | undefined): string { + if (typeof value !== 'string' || !value) { + return ''; + } + if (ESLINT_CONFIG_PATTERN.test(value)) { + return ''; + } + return `eslintrc: ${singleQuote(value)}`; +} + +/** Formats patterns as a string or array literal, omitting the plugin default. */ +function resolvePatterns(value: PluginAnswer | undefined): string { + if (typeof value === 'string') { + return resolvePatterns(value.split(',')); + } + if (!Array.isArray(value)) { + return ''; + } + const patterns = value + .map(s => s.trim()) + .filter(s => s !== '' && s !== DEFAULT_PATTERN) + .map(singleQuote); + if (patterns.length === 0) { + return ''; + } + if (patterns.length === 1) { + return `patterns: ${patterns.join('')}`; + } + return `patterns: [${patterns.join(', ')}]`; +} diff --git a/packages/plugin-eslint/src/lib/binding.unit.test.ts b/packages/plugin-eslint/src/lib/binding.unit.test.ts new file mode 100644 index 000000000..7f7f5c81f --- /dev/null +++ b/packages/plugin-eslint/src/lib/binding.unit.test.ts @@ -0,0 +1,189 @@ +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { directoryExists, readJsonFile } from '@code-pushup/utils'; +import { eslintSetupBinding } from './binding.js'; + +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + directoryExists: vi.fn().mockResolvedValue(false), + readJsonFile: vi.fn().mockRejectedValue(new Error('ENOENT')), + }; +}); + +describe('eslintSetupBinding', () => { + beforeEach(() => { + vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME); + }); + + describe('isRecommended', () => { + it('should detect flat config file', async () => { + vol.fromJSON({ 'eslint.config.js': '' }, MEMFS_VOLUME); + + await expect( + eslintSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeTrue(); + }); + + it('should detect legacy config file', async () => { + vol.fromJSON({ '.eslintrc.json': '' }, MEMFS_VOLUME); + + await expect( + eslintSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeTrue(); + }); + + it.each([{ field: 'dependencies' }, { field: 'devDependencies' }])( + 'should detect eslint in $field', + async ({ field }) => { + vi.mocked(readJsonFile).mockResolvedValue({ + [field]: { eslint: '^9.0.0' }, + }); + + await expect( + eslintSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeTrue(); + }, + ); + + it('should not recommend when no config or dependency found', async () => { + vi.mocked(readJsonFile).mockResolvedValue({}); + + await expect( + eslintSetupBinding.isRecommended(MEMFS_VOLUME), + ).resolves.toBeFalse(); + }); + }); + + describe('prompts', () => { + it('should pre-fill eslintrc with existing config filename', async () => { + vol.fromJSON({ 'eslint.config.ts': '' }, MEMFS_VOLUME); + + await expect( + eslintSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toContainEqual( + expect.objectContaining({ + key: 'eslint.eslintrc', + default: 'eslint.config.ts', + }), + ); + }); + + it('should leave eslintrc empty when no config file exists', async () => { + await expect( + eslintSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toContainEqual( + expect.objectContaining({ key: 'eslint.eslintrc', default: '' }), + ); + }); + + it('should default patterns to "src" when src directory exists', async () => { + vi.mocked(directoryExists).mockResolvedValue(true); + + await expect( + eslintSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toContainEqual( + expect.objectContaining({ key: 'eslint.patterns', default: 'src' }), + ); + }); + + it('should default patterns to "." when no src directory exists', async () => { + vi.mocked(directoryExists).mockResolvedValue(false); + + await expect( + eslintSetupBinding.prompts(MEMFS_VOLUME), + ).resolves.toContainEqual( + expect.objectContaining({ key: 'eslint.patterns', default: '.' }), + ); + }); + + it('should expose eslintrc, patterns and categories prompts', async () => { + const descriptors = await eslintSetupBinding.prompts(MEMFS_VOLUME); + expect(descriptors).toEqual([ + expect.objectContaining({ key: 'eslint.eslintrc' }), + expect.objectContaining({ key: 'eslint.patterns' }), + expect.objectContaining({ key: 'eslint.categories' }), + ]); + }); + }); + + describe('generateConfig', () => { + it('should omit eslintrc for standard config filenames', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': 'eslint.config.ts', + 'eslint.patterns': 'src', + 'eslint.categories': true, + }).pluginInit, + ).toBe("await eslintPlugin({ patterns: 'src' })"); + }); + + it('should include eslintrc for non-standard config paths', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': 'configs/eslint.config.js', + 'eslint.patterns': 'src', + 'eslint.categories': false, + }).pluginInit, + ).toBe( + "await eslintPlugin({ eslintrc: 'configs/eslint.config.js', patterns: 'src' })", + ); + }); + + it('should format comma-separated patterns as array', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': 'src, lib', + 'eslint.categories': false, + }).pluginInit, + ).toBe("await eslintPlugin({ patterns: ['src', 'lib'] })"); + }); + + it('should produce no-arg call when no options provided', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': false, + }).pluginInit, + ).toBe('await eslintPlugin()'); + }); + + it('should include categories when user confirms', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': true, + }).categories, + ).toHaveLength(2); + }); + + it('should omit categories when user declines', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': false, + }).categories, + ).toBeUndefined(); + }); + + it('should import from @code-pushup/eslint-plugin', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': false, + }).imports, + ).toEqual([ + { + moduleSpecifier: '@code-pushup/eslint-plugin', + defaultImport: 'eslintPlugin', + }, + ]); + }); + }); +}); diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index 5fbab71fc..edaca87db 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -4,6 +4,7 @@ import { pluginScoreTargetsSchema, } from '@code-pushup/models'; import { toArray } from '@code-pushup/utils'; +import { DEFAULT_PATTERN } from './constants.js'; const patternsSchema = z.union([z.string(), z.array(z.string()).min(1)]).meta({ description: @@ -17,7 +18,7 @@ const eslintrcSchema = z const eslintTargetObjectSchema = z .object({ eslintrc: eslintrcSchema.optional(), - patterns: patternsSchema.optional().default('.'), + patterns: patternsSchema.optional().default(DEFAULT_PATTERN), }) .meta({ title: 'ESLintTargetObject' }); diff --git a/packages/plugin-eslint/src/lib/constants.ts b/packages/plugin-eslint/src/lib/constants.ts index 90040e5e1..7ad462ecd 100644 --- a/packages/plugin-eslint/src/lib/constants.ts +++ b/packages/plugin-eslint/src/lib/constants.ts @@ -1,2 +1,3 @@ export const ESLINT_PLUGIN_SLUG = 'eslint'; export const ESLINT_PLUGIN_TITLE = 'ESLint'; +export const DEFAULT_PATTERN = '.'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e2b58c968..62abdd6c6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -196,6 +196,7 @@ export { } from './lib/monorepo.js'; export { hasCodePushUpDependency, + hasDependency, hasScript, hasWorkspacesEnabled, listPackages,