From f4939115c5281da504d3213ff943fae5e2942646 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 16 Mar 2026 13:02:47 -0400 Subject: [PATCH 1/4] refactor: extract plugin setup types to models --- packages/create-cli/src/lib/setup/types.ts | 74 +++------------------- packages/models/src/index.ts | 6 ++ packages/models/src/lib/plugin-setup.ts | 66 +++++++++++++++++++ 3 files changed, 80 insertions(+), 66 deletions(-) create mode 100644 packages/models/src/lib/plugin-setup.ts diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index aaca446b0..b19516025 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,6 +1,13 @@ -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, + 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 +31,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 +42,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/models/src/index.ts b/packages/models/src/index.ts index 217657763..6aac10e79 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -112,6 +112,12 @@ export { type PluginScoreTargets, type PluginUrls, } from './lib/plugin-config.js'; +export type { + ImportDeclarationStructure, + 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..07571ccb5 --- /dev/null +++ b/packages/models/src/lib/plugin-setup.ts @@ -0,0 +1,66 @@ +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[]; +}; + +/** 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[]; +}; + +/** + * 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; +}; From 059fcdee2ee0d5af8ebe5c2f76fdf5f3d1ee62dc Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 16 Mar 2026 13:06:44 -0400 Subject: [PATCH 2/4] build: allow tooling to depend on all scopes --- eslint.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 63b9cc76a..3602ddc17 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', From 0b4c70bc9eaa4473b53c798a9dcbc18a9c1a11ad Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 16 Mar 2026 16:34:38 -0400 Subject: [PATCH 3/4] feat(plugin-eslint): add setup wizard binding --- packages/create-cli/README.md | 12 ++ packages/create-cli/package.json | 1 + packages/create-cli/src/index.ts | 5 +- .../src/lib/setup/wizard.int.test.ts | 70 ++++++- packages/create-cli/src/lib/setup/wizard.ts | 15 +- packages/plugin-eslint/src/index.ts | 1 + packages/plugin-eslint/src/lib/binding.ts | 145 ++++++++++++++ .../src/lib/binding.unit.test.ts | 189 ++++++++++++++++++ packages/plugin-eslint/src/lib/config.ts | 3 +- packages/plugin-eslint/src/lib/constants.ts | 1 + 10 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 packages/plugin-eslint/src/lib/binding.ts create mode 100644 packages/plugin-eslint/src/lib/binding.unit.test.ts diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 1d105b7a3..9241536ce 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`** | `'yes'` \| `'no'` | `yes` | Add recommended categories | + ### Examples Run interactively (default): diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 6da6774cf..a1fc209a9 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.0", "@code-pushup/models": "0.119.0", "@code-pushup/utils": "0.119.0", "@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/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts index b434e86e0..0ca23687e 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': 'no', + }); + + 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/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..6e11c83da --- /dev/null +++ b/packages/plugin-eslint/src/lib/binding.ts @@ -0,0 +1,145 @@ +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { CategoryConfig, PluginSetupBinding } from '@code-pushup/models'; +import { directoryExists, readJsonFile, singleQuote } from '@code-pushup/utils'; +import { + DEFAULT_PATTERN, + ESLINT_PLUGIN_SLUG, + ESLINT_PLUGIN_TITLE, +} from './constants.js'; + +const PACKAGE_NAME = '@code-pushup/eslint-plugin'; +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: 'select', + choices: [ + { name: 'Yes', value: 'yes' }, + { name: 'No', value: 'no' }, + ], + default: 'yes', + }, + ], + generateConfig: (answers: Record) => { + const withCategories = answers['eslint.categories'] !== 'no'; + 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 ( + 'eslint' in (packageJson.dependencies ?? {}) || + 'eslint' in (packageJson.devDependencies ?? {}) + ); + } catch { + return false; + } +} + +/** Omits `eslintrc` for standard config filenames (ESLint discovers them automatically). */ +function resolveEslintrc(value: string | string[] | 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: string | string[] | undefined): string { + const items = typeof value === 'string' ? value.split(',') : (value ?? []); + const patterns = items + .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..fe3f0bcf8 --- /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': 'yes', + }).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': 'no', + }).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': 'no', + }).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': 'no', + }).pluginInit, + ).toBe('await eslintPlugin()'); + }); + + it('should include categories when user confirms', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': 'yes', + }).categories, + ).toHaveLength(2); + }); + + it('should omit categories when user declines', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': 'no', + }).categories, + ).toBeUndefined(); + }); + + it('should import from @code-pushup/eslint-plugin', () => { + expect( + eslintSetupBinding.generateConfig({ + 'eslint.eslintrc': '', + 'eslint.patterns': '', + 'eslint.categories': 'no', + }).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 = '.'; From a23ed2d62367c4838b3e9de39b4dd4ce49cdd3b1 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 17 Mar 2026 14:49:40 -0400 Subject: [PATCH 4/4] refactor(plugin-eslint): improve plugin setup binding --- packages/create-cli/README.md | 10 ++-- packages/create-cli/src/lib/setup/prompts.ts | 19 +++++-- packages/create-cli/src/lib/setup/types.ts | 1 + .../src/lib/setup/wizard.int.test.ts | 2 +- packages/models/src/index.ts | 1 + packages/models/src/lib/plugin-setup.ts | 13 ++++- packages/plugin-eslint/src/lib/binding.ts | 49 ++++++++++++------- .../src/lib/binding.unit.test.ts | 14 +++--- packages/utils/src/index.ts | 1 + 9 files changed, 71 insertions(+), 39 deletions(-) diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 9241536ce..8b3d2fed5 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -31,11 +31,11 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen #### 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`** | `'yes'` \| `'no'` | `yes` | Add recommended categories | +| 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 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 b19516025..fbc04c49e 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -3,6 +3,7 @@ import type { MonorepoTool } from '@code-pushup/utils'; export type { ImportDeclarationStructure, + PluginAnswer, PluginCodegenResult, PluginPromptDescriptor, PluginSetupBinding, 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 0ca23687e..db8116028 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -273,7 +273,7 @@ describe('runSetupWizard', () => { 'target-dir': outputDir, 'eslint.eslintrc': 'custom-eslint.config.js', 'eslint.patterns': 'src', - 'eslint.categories': 'no', + 'eslint.categories': false, }); await expect( diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 6aac10e79..b12a5d344 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -114,6 +114,7 @@ export { } from './lib/plugin-config.js'; export type { ImportDeclarationStructure, + PluginAnswer, PluginCodegenResult, PluginPromptDescriptor, PluginSetupBinding, diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index 07571ccb5..1b684016e 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -25,11 +25,17 @@ type CheckboxPrompt = PromptBase & { default: T[]; }; +type ConfirmPrompt = PromptBase & { + type: 'confirm'; + default: boolean; +}; + /** Declarative prompt definition used to collect plugin-specific options. */ export type PluginPromptDescriptor = | InputPrompt | SelectPrompt - | CheckboxPrompt; + | CheckboxPrompt + | ConfirmPrompt; export type ImportDeclarationStructure = { moduleSpecifier: string; @@ -38,6 +44,9 @@ export type ImportDeclarationStructure = { 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[]; @@ -61,6 +70,6 @@ export type PluginSetupBinding = { prompts?: (targetDir: string) => Promise; isRecommended?: (targetDir: string) => Promise; generateConfig: ( - answers: Record, + answers: Record, ) => PluginCodegenResult; }; diff --git a/packages/plugin-eslint/src/lib/binding.ts b/packages/plugin-eslint/src/lib/binding.ts index 6e11c83da..fcddc2de5 100644 --- a/packages/plugin-eslint/src/lib/binding.ts +++ b/packages/plugin-eslint/src/lib/binding.ts @@ -1,14 +1,27 @@ import { readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import path from 'node:path'; -import type { CategoryConfig, PluginSetupBinding } from '@code-pushup/models'; -import { directoryExists, readJsonFile, singleQuote } from '@code-pushup/utils'; +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 PACKAGE_NAME = '@code-pushup/eslint-plugin'; +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[] = [ @@ -64,16 +77,12 @@ export const eslintSetupBinding = { { key: 'eslint.categories', message: 'Add recommended categories (bug prevention, code style)?', - type: 'select', - choices: [ - { name: 'Yes', value: 'yes' }, - { name: 'No', value: 'no' }, - ], - default: 'yes', + type: 'confirm', + default: true, }, ], - generateConfig: (answers: Record) => { - const withCategories = answers['eslint.categories'] !== 'no'; + generateConfig: (answers: Record) => { + const withCategories = answers['eslint.categories'] !== false; const args = [ resolveEslintrc(answers['eslint.eslintrc']), resolvePatterns(answers['eslint.patterns']), @@ -108,17 +117,14 @@ async function isRecommended(targetDir: string): Promise { dependencies?: Record; devDependencies?: Record; }>(path.join(targetDir, 'package.json')); - return ( - 'eslint' in (packageJson.dependencies ?? {}) || - 'eslint' in (packageJson.devDependencies ?? {}) - ); + return hasDependency(packageJson, 'eslint'); } catch { return false; } } /** Omits `eslintrc` for standard config filenames (ESLint discovers them automatically). */ -function resolveEslintrc(value: string | string[] | undefined): string { +function resolveEslintrc(value: PluginAnswer | undefined): string { if (typeof value !== 'string' || !value) { return ''; } @@ -129,9 +135,14 @@ function resolveEslintrc(value: string | string[] | undefined): string { } /** Formats patterns as a string or array literal, omitting the plugin default. */ -function resolvePatterns(value: string | string[] | undefined): string { - const items = typeof value === 'string' ? value.split(',') : (value ?? []); - const patterns = items +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); diff --git a/packages/plugin-eslint/src/lib/binding.unit.test.ts b/packages/plugin-eslint/src/lib/binding.unit.test.ts index fe3f0bcf8..7f7f5c81f 100644 --- a/packages/plugin-eslint/src/lib/binding.unit.test.ts +++ b/packages/plugin-eslint/src/lib/binding.unit.test.ts @@ -114,7 +114,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': 'eslint.config.ts', 'eslint.patterns': 'src', - 'eslint.categories': 'yes', + 'eslint.categories': true, }).pluginInit, ).toBe("await eslintPlugin({ patterns: 'src' })"); }); @@ -124,7 +124,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': 'configs/eslint.config.js', 'eslint.patterns': 'src', - 'eslint.categories': 'no', + 'eslint.categories': false, }).pluginInit, ).toBe( "await eslintPlugin({ eslintrc: 'configs/eslint.config.js', patterns: 'src' })", @@ -136,7 +136,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': '', 'eslint.patterns': 'src, lib', - 'eslint.categories': 'no', + 'eslint.categories': false, }).pluginInit, ).toBe("await eslintPlugin({ patterns: ['src', 'lib'] })"); }); @@ -146,7 +146,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': '', 'eslint.patterns': '', - 'eslint.categories': 'no', + 'eslint.categories': false, }).pluginInit, ).toBe('await eslintPlugin()'); }); @@ -156,7 +156,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': '', 'eslint.patterns': '', - 'eslint.categories': 'yes', + 'eslint.categories': true, }).categories, ).toHaveLength(2); }); @@ -166,7 +166,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': '', 'eslint.patterns': '', - 'eslint.categories': 'no', + 'eslint.categories': false, }).categories, ).toBeUndefined(); }); @@ -176,7 +176,7 @@ describe('eslintSetupBinding', () => { eslintSetupBinding.generateConfig({ 'eslint.eslintrc': '', 'eslint.patterns': '', - 'eslint.categories': 'no', + 'eslint.categories': false, }).imports, ).toEqual([ { 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,