diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 875ca1b15..8cfd5d311 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`** | `boolean` | `true` | 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 ESLint categories | #### Coverage @@ -47,7 +47,16 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen | **`--coverage.testCommand`** | `string` | auto-detected | Command to run tests | | **`--coverage.types`** | `('function'` \| `'branch'` \| `'line')[]` | all | Coverage types to measure | | **`--coverage.continueOnFail`** | `boolean` | `true` | Continue if test command fails | -| **`--coverage.categories`** | `boolean` | `true` | Add code coverage category | +| **`--coverage.categories`** | `boolean` | `true` | Add Code coverage categories | + +#### JS Packages + +| Option | Type | Default | Description | +| ------------------------------------ | ---------------------------------------------------------- | ------------- | -------------------------- | +| **`--js-packages.packageManager`** | `'npm'` \| `'yarn-classic'` \| `'yarn-modern'` \| `'pnpm'` | auto-detected | Package manager | +| **`--js-packages.checks`** | `('audit'` \| `'outdated')[]` | both | Checks to run | +| **`--js-packages.dependencyGroups`** | `('prod'` \| `'dev'` \| `'optional')[]` | `prod`, `dev` | Dependency groups | +| **`--js-packages.categories`** | `boolean` | `true` | Add JS packages categories | ### Examples diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 0be6dc062..d50add54b 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -28,6 +28,7 @@ "dependencies": { "@code-pushup/coverage-plugin": "0.121.0", "@code-pushup/eslint-plugin": "0.121.0", + "@code-pushup/js-packages-plugin": "0.121.0", "@code-pushup/models": "0.121.0", "@code-pushup/utils": "0.121.0", "@inquirer/prompts": "^8.0.0", diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 97faa3c84..908f56946 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -3,6 +3,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { coverageSetupBinding } from '@code-pushup/coverage-plugin'; import { eslintSetupBinding } from '@code-pushup/eslint-plugin'; +import { jsPackagesSetupBinding } from '@code-pushup/js-packages-plugin'; import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { CI_PROVIDERS, @@ -12,10 +13,11 @@ import { } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; -// TODO: create, import and pass remaining plugin bindings (lighthouse, typescript, js-packages, jsdocs, axe) +// TODO: create, import and pass remaining plugin bindings (lighthouse, typescript, jsdocs, axe) const bindings: PluginSetupBinding[] = [ eslintSetupBinding, coverageSetupBinding, + jsPackagesSetupBinding, ]; const argv = await yargs(hideBin(process.argv)) diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index 55f58f7f5..2c3f6e9dc 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -147,7 +147,7 @@ function addPlugins( builder.addLine('// TODO: register some plugins', depth + 1); } else { builder.addLines( - plugins.map(({ pluginInit }) => `${pluginInit},`), + plugins.flatMap(({ pluginInit }) => pluginInit), depth + 1, ); } diff --git a/packages/create-cli/src/lib/setup/codegen.unit.test.ts b/packages/create-cli/src/lib/setup/codegen.unit.test.ts index ab28024a5..7fa514bdf 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -14,7 +14,7 @@ const ESLINT_PLUGIN: PluginCodegenResult = { defaultImport: 'eslintPlugin', }, ], - pluginInit: "await eslintPlugin({ patterns: '.' })", + pluginInit: ["await eslintPlugin({ patterns: '.' }),"], }; const ESLINT_CATEGORIES: CategoryConfig[] = [ @@ -58,7 +58,7 @@ describe('generateConfigSource', () => { defaultImport: 'eslintPlugin', }, ], - pluginInit: 'await eslintPlugin()', + pluginInit: ['await eslintPlugin(),'], }; expect(generateConfigSource([plugin], 'ts')).toMatchInlineSnapshot(` @@ -83,8 +83,9 @@ describe('generateConfigSource', () => { namedImports: ['eslintConfigFromAllNxProjects'], }, ], - pluginInit: - 'await eslintPlugin({ eslintrc: eslintConfigFromAllNxProjects() })', + pluginInit: [ + 'await eslintPlugin({ eslintrc: eslintConfigFromAllNxProjects() }),', + ], }; expect(generateConfigSource([plugin], 'ts')).toMatchInlineSnapshot(` @@ -100,7 +101,7 @@ describe('generateConfigSource', () => { `); }); - it('should generate config with multiple plugins', () => { + it('should generate config with multiple plugins including multiline', () => { const plugins: PluginCodegenResult[] = [ { imports: [ @@ -109,7 +110,7 @@ describe('generateConfigSource', () => { defaultImport: 'eslintPlugin', }, ], - pluginInit: 'await eslintPlugin()', + pluginInit: ['await eslintPlugin(),'], }, { imports: [ @@ -118,8 +119,11 @@ describe('generateConfigSource', () => { defaultImport: 'coveragePlugin', }, ], - pluginInit: - "await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] })", + pluginInit: [ + 'await coveragePlugin({', + " reports: ['coverage/lcov.info'],", + '}),', + ], }, ]; @@ -131,7 +135,9 @@ describe('generateConfigSource', () => { export default { plugins: [ await eslintPlugin(), - await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] }), + await coveragePlugin({ + reports: ['coverage/lcov.info'], + }), ], } satisfies CoreConfig; " @@ -160,7 +166,7 @@ describe('generateConfigSource', () => { defaultImport: 'eslintPlugin', }, ], - pluginInit: 'await eslintPlugin()', + pluginInit: ['await eslintPlugin(),'], }; expect(generateConfigSource([plugin], 'js')).toMatchInlineSnapshot(` @@ -185,7 +191,7 @@ describe('generateConfigSource', () => { defaultImport: 'eslintPlugin', }, ], - pluginInit: 'await eslintPlugin()', + pluginInit: ['await eslintPlugin(),'], }, { imports: [ @@ -194,8 +200,9 @@ describe('generateConfigSource', () => { defaultImport: 'coveragePlugin', }, ], - pluginInit: - "await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] })", + pluginInit: [ + "await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] }),", + ], }, ]; @@ -266,7 +273,7 @@ describe('generateConfigSource', () => { defaultImport: 'coveragePlugin', }, ], - pluginInit: 'await coveragePlugin()', + pluginInit: ['await coveragePlugin(),'], categories: [ { slug: 'code-coverage', diff --git a/packages/create-cli/src/lib/setup/plugins.unit.test.ts b/packages/create-cli/src/lib/setup/plugins.unit.test.ts index d9e75ffdc..694fd4f00 100644 --- a/packages/create-cli/src/lib/setup/plugins.unit.test.ts +++ b/packages/create-cli/src/lib/setup/plugins.unit.test.ts @@ -17,13 +17,13 @@ describe('validatePluginSlugs', () => { slug: 'eslint', title: 'ESLint', packageName: '@code-pushup/eslint-plugin', - generateConfig: () => ({ imports: [], pluginInit: '' }), + generateConfig: () => ({ imports: [], pluginInit: [] }), }, { slug: 'coverage', title: 'Code Coverage', packageName: '@code-pushup/coverage-plugin', - generateConfig: () => ({ imports: [], pluginInit: '' }), + generateConfig: () => ({ imports: [], pluginInit: [] }), }, ]; diff --git a/packages/create-cli/src/lib/setup/prompts.unit.test.ts b/packages/create-cli/src/lib/setup/prompts.unit.test.ts index ee309546d..65966df4c 100644 --- a/packages/create-cli/src/lib/setup/prompts.unit.test.ts +++ b/packages/create-cli/src/lib/setup/prompts.unit.test.ts @@ -96,19 +96,19 @@ describe('promptPluginSelection', () => { slug: 'eslint', title: 'ESLint', packageName: '@code-pushup/eslint-plugin', - generateConfig: () => ({ imports: [], pluginInit: '' }), + generateConfig: () => ({ imports: [], pluginInit: [] }), }, { slug: 'coverage', title: 'Code Coverage', packageName: '@code-pushup/coverage-plugin', - generateConfig: () => ({ imports: [], pluginInit: '' }), + generateConfig: () => ({ imports: [], pluginInit: [] }), }, { slug: 'lighthouse', title: 'Lighthouse', packageName: '@code-pushup/lighthouse-plugin', - generateConfig: () => ({ imports: [], pluginInit: '' }), + generateConfig: () => ({ imports: [], pluginInit: [] }), }, ]; 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 db8116028..7e544c3db 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -37,7 +37,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ defaultImport: 'alphaPlugin', }, ], - pluginInit: `alphaPlugin(${JSON.stringify(configPath)})`, + pluginInit: [`alphaPlugin(${JSON.stringify(configPath)}),`], }; }, }, @@ -53,7 +53,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ defaultImport: 'betaPlugin', }, ], - pluginInit: 'betaPlugin()', + pluginInit: ['betaPlugin(),'], }), }, ]; diff --git a/packages/create-cli/src/lib/setup/wizard.unit.test.ts b/packages/create-cli/src/lib/setup/wizard.unit.test.ts index 12fb57b44..04c882946 100644 --- a/packages/create-cli/src/lib/setup/wizard.unit.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.unit.test.ts @@ -30,7 +30,7 @@ const TEST_BINDING: PluginSetupBinding = { defaultImport: 'testPlugin', }, ], - pluginInit: 'testPlugin()', + pluginInit: ['testPlugin(),'], }), }; @@ -172,7 +172,7 @@ describe('runSetupWizard', () => { defaultImport: 'testPlugin', }, ], - pluginInit: 'testPlugin()', + pluginInit: ['testPlugin(),'], }), }; @@ -189,7 +189,7 @@ describe('runSetupWizard', () => { defaultImport: 'rootPlugin', }, ], - pluginInit: 'rootPlugin()', + pluginInit: ['rootPlugin(),'], }), }; diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index 8b6cfb8cf..bc335a46a 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -50,7 +50,7 @@ export type PluginAnswer = string | string[] | boolean; /** Code a plugin binding contributes to the generated config. */ export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; - pluginInit: string; + pluginInit: string[]; categories?: CategoryConfig[]; }; diff --git a/packages/plugin-coverage/src/lib/binding.ts b/packages/plugin-coverage/src/lib/binding.ts index 1abbc7965..187979814 100644 --- a/packages/plugin-coverage/src/lib/binding.ts +++ b/packages/plugin-coverage/src/lib/binding.ts @@ -8,6 +8,9 @@ import type { PluginSetupTree, } from '@code-pushup/models'; import { + answerArray, + answerBoolean, + answerString, hasDependency, pluralize, readJsonFile, @@ -119,7 +122,7 @@ export const coverageSetupBinding = { }, { key: 'coverage.categories', - message: 'Add code coverage category?', + message: 'Add Code coverage categories?', type: 'confirm', default: true, }, @@ -129,37 +132,28 @@ export const coverageSetupBinding = { answers: Record, tree?: PluginSetupTree, ) => { - const args = parseAnswers(answers); - const lcovConfigured = await configureLcovReporter(args, tree); + const options = parseAnswers(answers); + const lcovConfigured = await configureLcovReporter(options, tree); return { imports: [ { moduleSpecifier: PACKAGE_NAME, defaultImport: 'coveragePlugin' }, ], - pluginInit: formatPluginInit(args, lcovConfigured), - ...(args.categories ? { categories: CATEGORIES } : {}), + pluginInit: formatPluginInit(options, lcovConfigured), + ...(options.categories ? { categories: CATEGORIES } : {}), }; }, } satisfies PluginSetupBinding; function parseAnswers(answers: Record): CoverageOptions { - const string = (key: string) => { - const value = answers[key]; - return typeof value === 'string' ? value : ''; - }; - const types = answers['coverage.types']; return { - framework: string('coverage.framework'), - configFile: string('coverage.configFile'), - reportPath: string('coverage.reportPath') || DEFAULT_REPORT_PATH, - testCommand: string('coverage.testCommand'), - types: Array.isArray(types) - ? types - : (typeof types === 'string' ? types : '') - .split(',') - .map(item => item.trim()) - .filter(Boolean), - continueOnFail: answers['coverage.continueOnFail'] !== false, - categories: answers['coverage.categories'] !== false, + framework: answerString(answers, 'coverage.framework'), + configFile: answerString(answers, 'coverage.configFile'), + reportPath: + answerString(answers, 'coverage.reportPath') || DEFAULT_REPORT_PATH, + testCommand: answerString(answers, 'coverage.testCommand'), + types: answerArray(answers, 'coverage.types'), + continueOnFail: answerBoolean(answers, 'coverage.continueOnFail'), + categories: answerBoolean(answers, 'coverage.categories'), }; } @@ -190,27 +184,29 @@ async function configureLcovReporter( function formatPluginInit( options: CoverageOptions, lcovConfigured: boolean, -): string { +): string[] { const { reportPath, testCommand, types, continueOnFail } = options; const hasCustomTypes = types.length > 0 && types.length < ALL_COVERAGE_TYPES.length; const body = [ - `reports: [${singleQuote(reportPath)}]`, + `reports: [${singleQuote(reportPath)}],`, testCommand - ? `coverageToolCommand: { command: ${singleQuote(testCommand)} }` + ? `coverageToolCommand: { command: ${singleQuote(testCommand)} },` : '', hasCustomTypes - ? `coverageTypes: [${types.map(singleQuote).join(', ')}]` + ? `coverageTypes: [${types.map(singleQuote).join(', ')}],` : '', - continueOnFail ? '' : 'continueOnCommandFail: false', - ] - .filter(Boolean) - .join(',\n '); + continueOnFail ? '' : 'continueOnCommandFail: false,', + ].filter(Boolean); - const init = `await coveragePlugin({\n ${body},\n })`; - return lcovConfigured ? init : `${LCOV_COMMENT}\n ${init}`; + const init = [ + 'await coveragePlugin({', + ...body.map(line => ` ${line}`), + '}),', + ]; + return lcovConfigured ? init : [LCOV_COMMENT, ...init]; } async function isRecommended(targetDir: string): Promise { diff --git a/packages/plugin-coverage/src/lib/binding.unit.test.ts b/packages/plugin-coverage/src/lib/binding.unit.test.ts index bb68472a1..fbb6a183d 100644 --- a/packages/plugin-coverage/src/lib/binding.unit.test.ts +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -121,13 +121,13 @@ describe('coverageSetupBinding', () => { describe('generateConfig', () => { it('should generate vitest config', async () => { const { pluginInit } = await binding.generateConfig(defaultAnswers); - expect(pluginInit).toMatchInlineSnapshot(` - "// NOTE: Ensure your test config includes "lcov" in coverage reporters. - await coveragePlugin({ - reports: ['coverage/lcov.info'], - coverageToolCommand: { command: 'npx vitest run --coverage.enabled' }, - })" - `); + expect(pluginInit).toEqual([ + '// NOTE: Ensure your test config includes "lcov" in coverage reporters.', + 'await coveragePlugin({', + " reports: ['coverage/lcov.info'],", + " coverageToolCommand: { command: 'npx vitest run --coverage.enabled' },", + '}),', + ]); }); it('should generate jest config', async () => { @@ -136,13 +136,13 @@ describe('coverageSetupBinding', () => { 'coverage.framework': 'jest', 'coverage.testCommand': 'npx jest --coverage', }); - expect(pluginInit).toMatchInlineSnapshot(` - "// NOTE: Ensure your test config includes "lcov" in coverage reporters. - await coveragePlugin({ - reports: ['coverage/lcov.info'], - coverageToolCommand: { command: 'npx jest --coverage' }, - })" - `); + expect(pluginInit).toEqual([ + '// NOTE: Ensure your test config includes "lcov" in coverage reporters.', + 'await coveragePlugin({', + " reports: ['coverage/lcov.info'],", + " coverageToolCommand: { command: 'npx jest --coverage' },", + '}),', + ]); }); it('should omit coverageToolCommand when test command is empty', async () => { @@ -150,7 +150,11 @@ describe('coverageSetupBinding', () => { ...defaultAnswers, 'coverage.testCommand': '', }); - expect(pluginInit).not.toContain('coverageToolCommand'); + expect(pluginInit).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('coverageToolCommand'), + ]), + ); }); it('should use default report path when empty', async () => { @@ -158,7 +162,11 @@ describe('coverageSetupBinding', () => { ...defaultAnswers, 'coverage.reportPath': '', }); - expect(pluginInit).toContain("'coverage/lcov.info'"); + expect(pluginInit).toEqual( + expect.arrayContaining([ + expect.stringContaining("'coverage/lcov.info'"), + ]), + ); }); it('should use custom report path when provided', async () => { @@ -166,12 +174,18 @@ describe('coverageSetupBinding', () => { ...defaultAnswers, 'coverage.reportPath': 'dist/coverage/lcov.info', }); - expect(pluginInit).toContain("'dist/coverage/lcov.info'"); + expect(pluginInit).toEqual( + expect.arrayContaining([ + expect.stringContaining("'dist/coverage/lcov.info'"), + ]), + ); }); it('should omit coverageTypes when all selected', async () => { const { pluginInit } = await binding.generateConfig(defaultAnswers); - expect(pluginInit).not.toContain('coverageTypes'); + expect(pluginInit).not.toEqual( + expect.arrayContaining([expect.stringContaining('coverageTypes')]), + ); }); it('should include coverageTypes when subset selected', async () => { @@ -179,7 +193,11 @@ describe('coverageSetupBinding', () => { ...defaultAnswers, 'coverage.types': ['branch', 'line'], }); - expect(pluginInit).toContain("coverageTypes: ['branch', 'line']"); + expect(pluginInit).toEqual( + expect.arrayContaining([ + expect.stringContaining("coverageTypes: ['branch', 'line']"), + ]), + ); }); it('should disable continueOnCommandFail when declined', async () => { @@ -187,12 +205,20 @@ describe('coverageSetupBinding', () => { ...defaultAnswers, 'coverage.continueOnFail': false, }); - expect(pluginInit).toContain('continueOnCommandFail: false'); + expect(pluginInit).toEqual( + expect.arrayContaining([ + expect.stringContaining('continueOnCommandFail: false'), + ]), + ); }); it('should omit continueOnCommandFail when default', async () => { const { pluginInit } = await binding.generateConfig(defaultAnswers); - expect(pluginInit).not.toContain('continueOnCommandFail'); + expect(pluginInit).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('continueOnCommandFail'), + ]), + ); }); it('should omit categories when declined', async () => { @@ -227,7 +253,9 @@ describe('coverageSetupBinding', () => { "export default { test: { coverage: { reporter: ['lcov'] } } };", }); const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); - expect(pluginInit).not.toContain('NOTE'); + expect(pluginInit).not.toEqual( + expect.arrayContaining([expect.stringContaining('NOTE')]), + ); }); it('should not include comment when lcov is successfully added', async () => { @@ -236,7 +264,9 @@ describe('coverageSetupBinding', () => { "import { defineConfig } from 'vitest/config';\nexport default defineConfig({ test: { coverage: { reporter: ['text'] } } });", }); const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); - expect(pluginInit).not.toContain('NOTE'); + expect(pluginInit).not.toEqual( + expect.arrayContaining([expect.stringContaining('NOTE')]), + ); expect(tree.written.get('vitest.config.ts')).toContain('lcov'); }); @@ -245,13 +275,17 @@ describe('coverageSetupBinding', () => { ...defaultAnswers, 'coverage.framework': 'other', }); - expect(pluginInit).toContain('NOTE'); + expect(pluginInit).toEqual( + expect.arrayContaining([expect.stringContaining('NOTE')]), + ); }); it('should include comment when config file cannot be read', async () => { const tree = createMockTree({}); const { pluginInit } = await binding.generateConfig(vitestAnswers, tree); - expect(pluginInit).toContain('NOTE'); + expect(pluginInit).toEqual( + expect.arrayContaining([expect.stringContaining('NOTE')]), + ); }); it('should include comment when magicast cannot modify the file', async () => { @@ -266,7 +300,9 @@ describe('coverageSetupBinding', () => { }, tree, ); - expect(pluginInit).toContain('NOTE'); + expect(pluginInit).toEqual( + expect.arrayContaining([expect.stringContaining('NOTE')]), + ); }); }); }); diff --git a/packages/plugin-eslint/src/lib/binding.ts b/packages/plugin-eslint/src/lib/binding.ts index fcddc2de5..d2e72f716 100644 --- a/packages/plugin-eslint/src/lib/binding.ts +++ b/packages/plugin-eslint/src/lib/binding.ts @@ -7,6 +7,9 @@ import type { PluginSetupBinding, } from '@code-pushup/models'; import { + answerArray, + answerBoolean, + answerString, directoryExists, hasDependency, readJsonFile, @@ -54,6 +57,12 @@ const ESLINT_CATEGORIES: CategoryConfig[] = [ }, ]; +type EslintOptions = { + eslintrc: string; + patterns: string[]; + categories: boolean; +}; + export const eslintSetupBinding = { slug: ESLINT_PLUGIN_SLUG, title: ESLINT_PLUGIN_TITLE, @@ -76,36 +85,49 @@ export const eslintSetupBinding = { }, { key: 'eslint.categories', - message: 'Add recommended categories (bug prevention, code style)?', + message: 'Add ESLint categories?', 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); - + const options = parseAnswers(answers); return { imports: [ { moduleSpecifier: PACKAGE_NAME, defaultImport: 'eslintPlugin' }, ], - pluginInit: - args.length > 0 - ? `await eslintPlugin({ ${args.join(', ')} })` - : 'await eslintPlugin()', - ...(withCategories ? { categories: ESLINT_CATEGORIES } : {}), + pluginInit: formatPluginInit(options), + ...(options.categories ? { 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)); +function parseAnswers(answers: Record): EslintOptions { + return { + eslintrc: answerString(answers, 'eslint.eslintrc'), + patterns: answerArray(answers, 'eslint.patterns'), + categories: answerBoolean(answers, 'eslint.categories'), + }; +} + +function formatPluginInit({ eslintrc, patterns }: EslintOptions): string[] { + const useCustomEslintrc = + eslintrc !== '' && !ESLINT_CONFIG_PATTERN.test(eslintrc); + const customPatterns = patterns + .filter(s => s !== '' && s !== DEFAULT_PATTERN) + .map(singleQuote); + + const body = [ + useCustomEslintrc ? `eslintrc: ${singleQuote(eslintrc)}` : '', + customPatterns.length === 1 ? `patterns: ${customPatterns[0]}` : '', + customPatterns.length > 1 ? `patterns: [${customPatterns.join(', ')}]` : '', + ] + .filter(Boolean) + .join(', '); + + return body + ? [`await eslintPlugin({ ${body} }),`] + : ['await eslintPlugin(),']; } async function isRecommended(targetDir: string): Promise { @@ -123,34 +145,9 @@ async function isRecommended(targetDir: string): Promise { } } -/** 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(', ')}]`; +async function detectEslintConfig( + targetDir: string, +): Promise { + const files = await readdir(targetDir, { encoding: 'utf8' }); + return files.find(file => ESLINT_CONFIG_PATTERN.test(file)); } diff --git a/packages/plugin-eslint/src/lib/binding.unit.test.ts b/packages/plugin-eslint/src/lib/binding.unit.test.ts index 7f7f5c81f..94c476da5 100644 --- a/packages/plugin-eslint/src/lib/binding.unit.test.ts +++ b/packages/plugin-eslint/src/lib/binding.unit.test.ts @@ -116,7 +116,7 @@ describe('eslintSetupBinding', () => { 'eslint.patterns': 'src', 'eslint.categories': true, }).pluginInit, - ).toBe("await eslintPlugin({ patterns: 'src' })"); + ).toEqual(["await eslintPlugin({ patterns: 'src' }),"]); }); it('should include eslintrc for non-standard config paths', () => { @@ -126,9 +126,9 @@ describe('eslintSetupBinding', () => { 'eslint.patterns': 'src', 'eslint.categories': false, }).pluginInit, - ).toBe( - "await eslintPlugin({ eslintrc: 'configs/eslint.config.js', patterns: 'src' })", - ); + ).toEqual([ + "await eslintPlugin({ eslintrc: 'configs/eslint.config.js', patterns: 'src' }),", + ]); }); it('should format comma-separated patterns as array', () => { @@ -138,7 +138,7 @@ describe('eslintSetupBinding', () => { 'eslint.patterns': 'src, lib', 'eslint.categories': false, }).pluginInit, - ).toBe("await eslintPlugin({ patterns: ['src', 'lib'] })"); + ).toEqual(["await eslintPlugin({ patterns: ['src', 'lib'] }),"]); }); it('should produce no-arg call when no options provided', () => { @@ -148,7 +148,7 @@ describe('eslintSetupBinding', () => { 'eslint.patterns': '', 'eslint.categories': false, }).pluginInit, - ).toBe('await eslintPlugin()'); + ).toEqual(['await eslintPlugin(),']); }); it('should include categories when user confirms', () => { diff --git a/packages/plugin-js-packages/src/index.ts b/packages/plugin-js-packages/src/index.ts index 380d69cbd..c57658715 100644 --- a/packages/plugin-js-packages/src/index.ts +++ b/packages/plugin-js-packages/src/index.ts @@ -1,4 +1,5 @@ import { jsPackagesPlugin } from './lib/js-packages-plugin.js'; export default jsPackagesPlugin; +export { jsPackagesSetupBinding } from './lib/binding.js'; export type { JSPackagesPluginConfig } from './lib/config.js'; diff --git a/packages/plugin-js-packages/src/lib/binding.ts b/packages/plugin-js-packages/src/lib/binding.ts new file mode 100644 index 000000000..fe559b8db --- /dev/null +++ b/packages/plugin-js-packages/src/lib/binding.ts @@ -0,0 +1,187 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { + CategoryConfig, + PluginAnswer, + PluginSetupBinding, +} from '@code-pushup/models'; +import { + answerArray, + answerBoolean, + answerString, + fileExists, + singleQuote, +} from '@code-pushup/utils'; +import type { PackageManagerId } from './config.js'; +import { + DEFAULT_CHECKS, + DEFAULT_DEPENDENCY_GROUPS, + JS_PACKAGES_PLUGIN_SLUG, + JS_PACKAGES_PLUGIN_TITLE, +} from './constants.js'; +import { derivePackageManager } from './package-managers/derive-package-manager.js'; + +const { name: PACKAGE_NAME } = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +const DEFAULT_PACKAGE_MANAGER = 'npm'; + +const PACKAGE_MANAGERS = [ + { name: 'npm', value: DEFAULT_PACKAGE_MANAGER }, + { name: 'yarn (classic)', value: 'yarn-classic' }, + { name: 'yarn (modern)', value: 'yarn-modern' }, + { name: 'pnpm', value: 'pnpm' }, +] as const; + +const CHECKS = [ + { name: 'audit (security vulnerabilities)', value: 'audit' }, + { name: 'outdated (outdated dependencies)', value: 'outdated' }, +] as const; + +const DEPENDENCY_GROUPS = [ + { name: 'production', value: 'prod' }, + { name: 'development', value: 'dev' }, + { name: 'optional', value: 'optional' }, +] as const; + +const CATEGORIES = [ + { + check: 'audit', + slug: 'security', + title: 'Security', + description: 'Finds known **vulnerabilities** in third-party packages.', + }, + { + check: 'outdated', + slug: 'updates', + title: 'Updates', + description: 'Finds **outdated** third-party packages.', + }, +]; + +type JsPackagesOptions = { + packageManager: string; + checks: string[]; + dependencyGroups: string[]; + categories: boolean; +}; + +export const jsPackagesSetupBinding = { + slug: JS_PACKAGES_PLUGIN_SLUG, + title: JS_PACKAGES_PLUGIN_TITLE, + packageName: PACKAGE_NAME, + isRecommended, + prompts: async (targetDir: string) => { + const packageManager = await detectPackageManager(targetDir); + return [ + { + key: 'js-packages.packageManager', + message: 'Package manager', + type: 'select', + choices: [...PACKAGE_MANAGERS], + default: packageManager, + }, + { + key: 'js-packages.checks', + message: 'Checks to run', + type: 'checkbox', + choices: [...CHECKS], + default: [...DEFAULT_CHECKS], + }, + { + key: 'js-packages.dependencyGroups', + message: 'Dependency groups', + type: 'checkbox', + choices: [...DEPENDENCY_GROUPS], + default: [...DEFAULT_DEPENDENCY_GROUPS], + }, + { + key: 'js-packages.categories', + message: 'Add JS packages categories?', + type: 'confirm', + default: true, + }, + ]; + }, + generateConfig: (answers: Record) => { + const options = parseAnswers(answers); + return { + imports: [ + { moduleSpecifier: PACKAGE_NAME, defaultImport: 'jsPackagesPlugin' }, + ], + pluginInit: formatPluginInit(options), + ...(options.categories ? { categories: createCategories(options) } : {}), + }; + }, +} satisfies PluginSetupBinding; + +function parseAnswers( + answers: Record, +): JsPackagesOptions { + return { + packageManager: + answerString(answers, 'js-packages.packageManager') || + DEFAULT_PACKAGE_MANAGER, + checks: answerArray(answers, 'js-packages.checks'), + dependencyGroups: answerArray(answers, 'js-packages.dependencyGroups'), + categories: answerBoolean(answers, 'js-packages.categories'), + }; +} + +function formatPluginInit(options: JsPackagesOptions): string[] { + const { packageManager, checks, dependencyGroups } = options; + + const hasNonDefaultChecks = + checks.length > 0 && checks.length < DEFAULT_CHECKS.length; + const hasNonDefaultDepGroups = + dependencyGroups.length !== DEFAULT_DEPENDENCY_GROUPS.length || + !DEFAULT_DEPENDENCY_GROUPS.every(g => dependencyGroups.includes(g)); + + const body = [ + `packageManager: ${singleQuote(packageManager)},`, + hasNonDefaultChecks + ? `checks: [${checks.map(singleQuote).join(', ')}],` + : '', + hasNonDefaultDepGroups + ? `dependencyGroups: [${dependencyGroups.map(singleQuote).join(', ')}],` + : '', + ].filter(Boolean); + + return ['await jsPackagesPlugin({', ...body.map(line => ` ${line}`), '}),']; +} + +function createCategories({ + packageManager, + checks, +}: JsPackagesOptions): CategoryConfig[] { + return CATEGORIES.filter(({ check }) => checks.includes(check)).map( + ({ check, slug, title, description }) => ({ + slug, + title, + description, + refs: [ + { + type: 'group', + plugin: JS_PACKAGES_PLUGIN_SLUG, + slug: `${packageManager}-${check}`, + weight: 1, + }, + ], + }), + ); +} + +async function isRecommended(targetDir: string): Promise { + return fileExists(path.join(targetDir, 'package.json')); +} + +async function detectPackageManager( + targetDir: string, +): Promise { + try { + return await derivePackageManager(targetDir); + } catch { + return DEFAULT_PACKAGE_MANAGER; + } +} diff --git a/packages/plugin-js-packages/src/lib/binding.unit.test.ts b/packages/plugin-js-packages/src/lib/binding.unit.test.ts new file mode 100644 index 000000000..71210090a --- /dev/null +++ b/packages/plugin-js-packages/src/lib/binding.unit.test.ts @@ -0,0 +1,194 @@ +import { vol } from 'memfs'; +import type { PluginAnswer } from '@code-pushup/models'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { jsPackagesSetupBinding as binding } from './binding.js'; + +const defaultAnswers: Record = { + 'js-packages.packageManager': 'npm', + 'js-packages.checks': ['audit', 'outdated'], + 'js-packages.dependencyGroups': ['prod', 'dev'], + 'js-packages.categories': true, +}; + +describe('jsPackagesSetupBinding', () => { + beforeEach(() => { + vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME); + }); + + describe('isRecommended', () => { + it('should recommend when package.json exists', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); + }); + + it('should not recommend when package.json is missing', async () => { + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeFalse(); + }); + }); + + describe('prompts', () => { + it('should detect npm from package-lock.json', async () => { + vol.fromJSON({ 'package-lock.json': '' }, MEMFS_VOLUME); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'js-packages.packageManager', default: 'npm' }, + ]); + }); + + it('should detect pnpm from pnpm-lock.yaml', async () => { + vol.fromJSON({ 'pnpm-lock.yaml': '' }, MEMFS_VOLUME); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'js-packages.packageManager', default: 'pnpm' }, + ]); + }); + + it('should detect npm from packageManager field in package.json', async () => { + vol.fromJSON( + { 'package.json': JSON.stringify({ packageManager: 'npm@10.0.0' }) }, + MEMFS_VOLUME, + ); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'js-packages.packageManager', default: 'npm' }, + ]); + }); + + it('should detect yarn-modern from packageManager field in package.json', async () => { + vol.fromJSON( + { 'package.json': JSON.stringify({ packageManager: 'yarn@4.0.0' }) }, + MEMFS_VOLUME, + ); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'js-packages.packageManager', default: 'yarn-modern' }, + ]); + }); + + it('should default to npm when no lock file or packageManager field is found', async () => { + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'js-packages.packageManager', default: 'npm' }, + ]); + }); + }); + + describe('generateConfig', () => { + it('should always include packageManager in plugin init', () => { + expect(binding.generateConfig(defaultAnswers).pluginInit).toEqual( + expect.arrayContaining([ + expect.stringContaining("packageManager: 'npm'"), + ]), + ); + }); + + it('should omit checks when all defaults (audit and outdated) are selected', () => { + expect(binding.generateConfig(defaultAnswers).pluginInit).not.toEqual( + expect.arrayContaining([expect.stringContaining('checks')]), + ); + }); + + it('should include checks when only audit is selected', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'js-packages.checks': ['audit'], + }).pluginInit, + ).toEqual( + expect.arrayContaining([expect.stringContaining("checks: ['audit']")]), + ); + }); + + it('should omit dependencyGroups when default prod and dev are selected', () => { + expect(binding.generateConfig(defaultAnswers).pluginInit).not.toEqual( + expect.arrayContaining([expect.stringContaining('dependencyGroups')]), + ); + }); + + it('should include dependencyGroups when optionalDependencies are added', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'js-packages.dependencyGroups': ['prod', 'dev', 'optional'], + }).pluginInit, + ).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "dependencyGroups: ['prod', 'dev', 'optional']", + ), + ]), + ); + }); + + it('should generate security category for audit check', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'js-packages.checks': ['audit'], + }).categories, + ).toEqual([expect.objectContaining({ slug: 'security' })]); + }); + + it('should generate updates category for outdated check', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'js-packages.checks': ['outdated'], + }).categories, + ).toEqual([expect.objectContaining({ slug: 'updates' })]); + }); + + it('should generate both categories when audit and outdated checks are selected', () => { + expect(binding.generateConfig(defaultAnswers).categories).toEqual([ + expect.objectContaining({ slug: 'security' }), + expect.objectContaining({ slug: 'updates' }), + ]); + }); + + it('should use package manager as prefix in category group refs', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'js-packages.packageManager': 'pnpm', + }).categories, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + refs: [expect.objectContaining({ slug: 'pnpm-audit' })], + }), + expect.objectContaining({ + refs: [expect.objectContaining({ slug: 'pnpm-outdated' })], + }), + ]), + ); + }); + + it('should omit categories when declined', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'js-packages.categories': false, + }).categories, + ).toBeUndefined(); + }); + + it('should import from @code-pushup/js-packages-plugin', () => { + expect(binding.generateConfig(defaultAnswers).imports).toEqual([ + { + moduleSpecifier: '@code-pushup/js-packages-plugin', + defaultImport: 'jsPackagesPlugin', + }, + ]); + }); + }); +}); diff --git a/packages/plugin-js-packages/src/lib/config.ts b/packages/plugin-js-packages/src/lib/config.ts index dba8278a2..e65e965df 100644 --- a/packages/plugin-js-packages/src/lib/config.ts +++ b/packages/plugin-js-packages/src/lib/config.ts @@ -4,7 +4,11 @@ import { issueSeveritySchema, pluginScoreTargetsSchema, } from '@code-pushup/models'; -import { defaultAuditLevelMapping } from './constants.js'; +import { + DEFAULT_CHECKS, + DEFAULT_DEPENDENCY_GROUPS, + defaultAuditLevelMapping, +} from './constants.js'; export const dependencyGroups = ['prod', 'dev', 'optional'] as const; const dependencyGroupSchema = z.enum(dependencyGroups); @@ -63,7 +67,7 @@ export const jsPackagesPluginConfigSchema = z checks: z .array(packageCommandSchema) .min(1) - .default(['audit', 'outdated']) + .default([...DEFAULT_CHECKS]) .meta({ description: 'Package manager commands to be run. Defaults to both audit and outdated.', @@ -74,7 +78,7 @@ export const jsPackagesPluginConfigSchema = z dependencyGroups: z .array(dependencyGroupSchema) .min(1) - .default(['prod', 'dev']), + .default([...DEFAULT_DEPENDENCY_GROUPS]), auditLevelMapping: z .partialRecord(packageAuditLevelSchema, issueSeveritySchema) .default(defaultAuditLevelMapping) diff --git a/packages/plugin-js-packages/src/lib/constants.ts b/packages/plugin-js-packages/src/lib/constants.ts index 4f555f0ed..5871348b2 100644 --- a/packages/plugin-js-packages/src/lib/constants.ts +++ b/packages/plugin-js-packages/src/lib/constants.ts @@ -5,6 +5,9 @@ import type { DependencyGroupLong } from './runner/outdated/types.js'; export const JS_PACKAGES_PLUGIN_SLUG = 'js-packages'; export const JS_PACKAGES_PLUGIN_TITLE = 'JS packages'; +export const DEFAULT_CHECKS = ['audit', 'outdated'] as const; +export const DEFAULT_DEPENDENCY_GROUPS = ['prod', 'dev'] as const; + export const defaultAuditLevelMapping: Record< PackageAuditLevel, IssueSeverity diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 62abdd6c6..46218b989 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -194,6 +194,11 @@ export { MONOREPO_TOOLS, type MonorepoTool, } from './lib/monorepo.js'; +export { + answerArray, + answerBoolean, + answerString, +} from './lib/plugin-answers.js'; export { hasCodePushUpDependency, hasDependency, diff --git a/packages/utils/src/lib/plugin-answers.ts b/packages/utils/src/lib/plugin-answers.ts new file mode 100644 index 000000000..056eab612 --- /dev/null +++ b/packages/utils/src/lib/plugin-answers.ts @@ -0,0 +1,33 @@ +import type { PluginAnswer } from '@code-pushup/models'; + +/** Extracts a string value from a plugin answer, defaulting to `''`. */ +export function answerString( + answers: Record, + key: string, +): string { + const value = answers[key]; + return typeof value === 'string' ? value : ''; +} + +/** Extracts a string array from a plugin answer, splitting CSV strings as fallback. */ +export function answerArray( + answers: Record, + key: string, +): string[] { + const value = answers[key]; + if (Array.isArray(value)) { + return value; + } + return (typeof value === 'string' ? value : '') + .split(',') + .map(item => item.trim()) + .filter(Boolean); +} + +/** Extracts a boolean from a plugin answer, defaulting to `true`. */ +export function answerBoolean( + answers: Record, + key: string, +): boolean { + return answers[key] !== false; +}