diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 8cfd5d311..bbef5ef02 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -58,6 +58,13 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen | **`--js-packages.dependencyGroups`** | `('prod'` \| `'dev'` \| `'optional')[]` | `prod`, `dev` | Dependency groups | | **`--js-packages.categories`** | `boolean` | `true` | Add JS packages categories | +#### TypeScript + +| Option | Type | Default | Description | +| ----------------------------- | --------- | ------------- | ------------------------- | +| **`--typescript.tsconfig`** | `string` | auto-detected | TypeScript config file | +| **`--typescript.categories`** | `boolean` | `true` | Add TypeScript categories | + ### Examples Run interactively (default): diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 28421ba5d..c2b5dad79 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -30,6 +30,7 @@ "@code-pushup/eslint-plugin": "0.122.0", "@code-pushup/js-packages-plugin": "0.122.0", "@code-pushup/models": "0.122.0", + "@code-pushup/typescript-plugin": "0.122.0", "@code-pushup/utils": "0.122.0", "@inquirer/prompts": "^8.0.0", "yaml": "^2.5.1", diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 908f56946..b0184249d 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -4,6 +4,7 @@ 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 { typescriptSetupBinding } from '@code-pushup/typescript-plugin'; import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { CI_PROVIDERS, @@ -13,11 +14,12 @@ import { } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; -// TODO: create, import and pass remaining plugin bindings (lighthouse, typescript, jsdocs, axe) +// TODO: create, import and pass remaining plugin bindings (lighthouse, jsdocs, axe) const bindings: PluginSetupBinding[] = [ eslintSetupBinding, coverageSetupBinding, jsPackagesSetupBinding, + typescriptSetupBinding, ]; const argv = await yargs(hideBin(process.argv)) diff --git a/packages/plugin-typescript/src/index.ts b/packages/plugin-typescript/src/index.ts index 5d0fd8d0d..94ee91c8d 100644 --- a/packages/plugin-typescript/src/index.ts +++ b/packages/plugin-typescript/src/index.ts @@ -2,6 +2,7 @@ import { typescriptPlugin } from './lib/typescript-plugin.js'; export default typescriptPlugin; +export { typescriptSetupBinding } from './lib/binding.js'; export { TYPESCRIPT_PLUGIN_SLUG } from './lib/constants.js'; export { typescriptPluginConfigSchema, diff --git a/packages/plugin-typescript/src/lib/binding.ts b/packages/plugin-typescript/src/lib/binding.ts new file mode 100644 index 000000000..3a68502e7 --- /dev/null +++ b/packages/plugin-typescript/src/lib/binding.ts @@ -0,0 +1,107 @@ +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 { + answerBoolean, + answerString, + fileExists, + singleQuote, +} from '@code-pushup/utils'; +import { + DEFAULT_TS_CONFIG, + TSCONFIG_PATTERN, + TYPESCRIPT_PLUGIN_SLUG, + TYPESCRIPT_PLUGIN_TITLE, +} from './constants.js'; + +const { name: PACKAGE_NAME } = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +const TYPESCRIPT_CATEGORIES: CategoryConfig[] = [ + { + slug: 'bug-prevention', + title: 'Bug prevention', + description: 'Type checks that find **potential bugs** in your code.', + refs: [ + { + type: 'group', + plugin: TYPESCRIPT_PLUGIN_SLUG, + slug: 'problems', + weight: 1, + }, + ], + }, +]; + +type TypescriptOptions = { + tsconfig: string; + categories: boolean; +}; + +export const typescriptSetupBinding = { + slug: TYPESCRIPT_PLUGIN_SLUG, + title: TYPESCRIPT_PLUGIN_TITLE, + packageName: PACKAGE_NAME, + isRecommended, + prompts: async (targetDir: string) => { + const tsconfig = await detectTsconfig(targetDir); + return [ + { + key: 'typescript.tsconfig', + message: 'TypeScript config file', + type: 'input', + default: tsconfig, + }, + { + key: 'typescript.categories', + message: 'Add TypeScript categories?', + type: 'confirm', + default: true, + }, + ]; + }, + generateConfig: (answers: Record) => { + const options = parseAnswers(answers); + return { + imports: [ + { moduleSpecifier: PACKAGE_NAME, defaultImport: 'typescriptPlugin' }, + ], + pluginInit: formatPluginInit(options), + ...(options.categories ? { categories: TYPESCRIPT_CATEGORIES } : {}), + }; + }, +} satisfies PluginSetupBinding; + +function parseAnswers( + answers: Record, +): TypescriptOptions { + return { + tsconfig: answerString(answers, 'typescript.tsconfig') || DEFAULT_TS_CONFIG, + categories: answerBoolean(answers, 'typescript.categories'), + }; +} + +function formatPluginInit({ tsconfig }: TypescriptOptions): string[] { + return tsconfig === DEFAULT_TS_CONFIG + ? ['typescriptPlugin(),'] + : ['typescriptPlugin({', ` tsconfig: ${singleQuote(tsconfig)},`, '}),']; +} + +async function isRecommended(targetDir: string): Promise { + return ( + (await fileExists(path.join(targetDir, 'tsconfig.json'))) || + (await fileExists(path.join(targetDir, 'tsconfig.base.json'))) + ); +} + +async function detectTsconfig(targetDir: string): Promise { + const files = await readdir(targetDir, { encoding: 'utf8' }); + const match = files.find(file => TSCONFIG_PATTERN.test(file)); + return match ?? DEFAULT_TS_CONFIG; +} diff --git a/packages/plugin-typescript/src/lib/binding.unit.test.ts b/packages/plugin-typescript/src/lib/binding.unit.test.ts new file mode 100644 index 000000000..89962adf9 --- /dev/null +++ b/packages/plugin-typescript/src/lib/binding.unit.test.ts @@ -0,0 +1,135 @@ +import { vol } from 'memfs'; +import type { PluginAnswer } from '@code-pushup/models'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { typescriptSetupBinding as binding } from './binding.js'; + +const defaultAnswers: Record = { + 'typescript.tsconfig': 'tsconfig.json', + 'typescript.categories': true, +}; + +describe('typescriptSetupBinding', () => { + beforeEach(() => { + vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME); + }); + + describe('isRecommended', () => { + it('should recommend when tsconfig.json exists', async () => { + vol.fromJSON({ 'tsconfig.json': '{}' }, MEMFS_VOLUME); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); + }); + + it('should recommend when tsconfig.base.json exists', async () => { + vol.fromJSON({ 'tsconfig.base.json': '{}' }, MEMFS_VOLUME); + + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeTrue(); + }); + + it('should not recommend when no tsconfig found', async () => { + await expect(binding.isRecommended(MEMFS_VOLUME)).resolves.toBeFalse(); + }); + }); + + describe('prompts', () => { + it('should detect tsconfig.json as default', async () => { + vol.fromJSON({ 'tsconfig.json': '{}' }, MEMFS_VOLUME); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'typescript.tsconfig', default: 'tsconfig.json' }, + ]); + }); + + it('should detect tsconfig.base.json when present', async () => { + vol.fromJSON({ 'tsconfig.base.json': '{}' }, MEMFS_VOLUME); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'typescript.tsconfig', default: 'tsconfig.base.json' }, + ]); + }); + + it('should detect tsconfig.app.json when present', async () => { + vol.fromJSON({ 'tsconfig.app.json': '{}' }, MEMFS_VOLUME); + + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'typescript.tsconfig', default: 'tsconfig.app.json' }, + ]); + }); + + it('should default to tsconfig.json when no tsconfig found', async () => { + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'typescript.tsconfig', default: 'tsconfig.json' }, + ]); + }); + + it('should default categories confirmation to true', async () => { + await expect( + binding.prompts(MEMFS_VOLUME), + ).resolves.toIncludeAllPartialMembers([ + { key: 'typescript.categories', type: 'confirm', default: true }, + ]); + }); + }); + + describe('generateConfig', () => { + it('should omit tsconfig option when using default tsconfig.json', () => { + expect(binding.generateConfig(defaultAnswers).pluginInit).toEqual([ + 'typescriptPlugin(),', + ]); + }); + + it('should include tsconfig when non-default path provided', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'typescript.tsconfig': 'tsconfig.base.json', + }).pluginInit, + ).toEqual([ + 'typescriptPlugin({', + " tsconfig: 'tsconfig.base.json',", + '}),', + ]); + }); + + it('should generate bug-prevention category from problems group when confirmed', () => { + expect(binding.generateConfig(defaultAnswers).categories).toEqual([ + expect.objectContaining({ + slug: 'bug-prevention', + refs: [ + expect.objectContaining({ + type: 'group', + plugin: 'typescript', + slug: 'problems', + }), + ], + }), + ]); + }); + + it('should omit categories when declined', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'typescript.categories': false, + }).categories, + ).toBeUndefined(); + }); + + it('should import from @code-pushup/typescript-plugin', () => { + expect(binding.generateConfig(defaultAnswers).imports).toEqual([ + { + moduleSpecifier: '@code-pushup/typescript-plugin', + defaultImport: 'typescriptPlugin', + }, + ]); + }); + }); +}); diff --git a/packages/plugin-typescript/src/lib/constants.ts b/packages/plugin-typescript/src/lib/constants.ts index 7f018d91a..9de652fe0 100644 --- a/packages/plugin-typescript/src/lib/constants.ts +++ b/packages/plugin-typescript/src/lib/constants.ts @@ -8,6 +8,8 @@ export const TYPESCRIPT_PLUGIN_TITLE = 'TypeScript'; export const DEFAULT_TS_CONFIG = 'tsconfig.json'; +export const TSCONFIG_PATTERN = /^tsconfig(\..+)?\.json$/; + const AUDIT_DESCRIPTIONS: Record = { 'semantic-errors': 'Errors that occur during type checking and type inference', diff --git a/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts b/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts index 3da0f30cb..0c6647d82 100644 --- a/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts +++ b/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts @@ -3,10 +3,9 @@ import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { readConfigFile, sys } from 'typescript'; import { loadNxProjectGraph, logger, pluralizeToken } from '@code-pushup/utils'; +import { TSCONFIG_PATTERN } from '../constants.js'; import { formatMetaLog } from '../format.js'; -const TSCONFIG_PATTERN = /^tsconfig(\..+)?\.json$/; - /** * Returns true only if config explicitly defines files or include with values. */