diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index 2683f16a8..e1184b815 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -1,5 +1,6 @@ import path from 'node:path'; -import { toUnixPath } from '@code-pushup/utils'; +import type { CategoryRef } from '@code-pushup/models'; +import { mergeCategoriesBySlug, toUnixPath } from '@code-pushup/utils'; import type { ConfigFileFormat, ImportDeclarationStructure, @@ -43,11 +44,13 @@ export function generateConfigSource( if (format === 'ts') { builder.addLine('export default {'); addPlugins(builder, plugins); + addCategories(builder, plugins); builder.addLine('} satisfies CoreConfig;'); } else { builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */"); builder.addLine('export default {'); addPlugins(builder, plugins); + addCategories(builder, plugins); builder.addLine('};'); } return builder.toString(); @@ -172,6 +175,52 @@ function addPresetExport( } builder.addLine('return {', 1); addPlugins(builder, plugins, 2); + addCategories(builder, plugins, 2); builder.addLine('};', 1); builder.addLine('}'); } + +function addCategories( + builder: CodeBuilder, + plugins: PluginCodegenResult[], + depth = 1, +): void { + const categories = mergeCategoriesBySlug( + plugins.flatMap(p => p.categories ?? []), + ); + if (categories.length === 0) { + return; + } + builder.addLine('categories: [', depth); + categories.forEach(({ slug, title, description, docsUrl, refs }) => { + builder.addLine('{', depth + 1); + builder.addLine(`slug: '${slug}',`, depth + 2); + builder.addLine(`title: ${toJsStringLiteral(title)},`, depth + 2); + if (description) { + builder.addLine( + `description: ${toJsStringLiteral(description)},`, + depth + 2, + ); + } + if (docsUrl) { + builder.addLine(`docsUrl: ${toJsStringLiteral(docsUrl)},`, depth + 2); + } + builder.addLine('refs: [', depth + 2); + builder.addLines(refs.map(formatCategoryRef), depth + 3); + builder.addLine('],', depth + 2); + builder.addLine('},', depth + 1); + }); + builder.addLine('],', depth); +} + +function formatCategoryRef(ref: CategoryRef): string { + return `{ type: '${ref.type}', plugin: '${ref.plugin}', slug: '${ref.slug}', weight: ${ref.weight} },`; +} + +/** Wraps a value in single-quoted JS string literal with special characters escaped. */ +function toJsStringLiteral(value: string): string { + const inner = JSON.stringify(value) + .slice(1, -1) + .replace(/'/g, String.raw`\'`); + return `'${inner}'`; +} 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 c320f8f1e..ab28024a5 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -1,3 +1,4 @@ +import type { CategoryConfig } from '@code-pushup/models'; import { computeRelativePresetImport, generateConfigSource, @@ -16,6 +17,24 @@ const ESLINT_PLUGIN: PluginCodegenResult = { pluginInit: "await eslintPlugin({ patterns: '.' })", }; +const ESLINT_CATEGORIES: CategoryConfig[] = [ + { + slug: 'bug-prevention', + title: 'Bug prevention', + refs: [{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }], + }, + { + slug: 'code-style', + title: 'Code style', + refs: [{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }], + }, +]; + +const ESLINT_PLUGIN_WITH_CATEGORIES: PluginCodegenResult = { + ...ESLINT_PLUGIN, + categories: ESLINT_CATEGORIES, +}; + describe('generateConfigSource', () => { describe('TypeScript format', () => { it('should generate config with TODO placeholder when no plugins provided', () => { @@ -201,6 +220,155 @@ describe('generateConfigSource', () => { ); }); }); + + describe('categories', () => { + it('should include categories block when plugin provides categories', () => { + expect(generateConfigSource([ESLINT_PLUGIN_WITH_CATEGORIES], 'ts')) + .toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + await eslintPlugin({ patterns: '.' }), + ], + categories: [ + { + slug: 'bug-prevention', + title: 'Bug prevention', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + ], + }, + { + slug: 'code-style', + title: 'Code style', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }, + ], + }, + ], + } satisfies CoreConfig; + " + `); + }); + + it('should omit categories block when no categories provided', () => { + const source = generateConfigSource([ESLINT_PLUGIN], 'ts'); + expect(source).not.toContain('categories'); + }); + + it('should merge categories from multiple plugins', () => { + const coveragePlugin: PluginCodegenResult = { + imports: [ + { + moduleSpecifier: '@code-pushup/coverage-plugin', + defaultImport: 'coveragePlugin', + }, + ], + pluginInit: 'await coveragePlugin()', + categories: [ + { + slug: 'code-coverage', + title: 'Code coverage', + refs: [ + { + type: 'group', + plugin: 'coverage', + slug: 'coverage', + weight: 1, + }, + ], + }, + ], + }; + const source = generateConfigSource( + [ESLINT_PLUGIN_WITH_CATEGORIES, coveragePlugin], + 'ts', + ); + expect(source).toContain("slug: 'bug-prevention'"); + expect(source).toContain("slug: 'code-style'"); + expect(source).toContain("slug: 'code-coverage'"); + }); + + it('should include categories in JS format config', () => { + const source = generateConfigSource( + [ESLINT_PLUGIN_WITH_CATEGORIES], + 'js', + ); + expect(source).toContain('categories: ['); + expect(source).toContain("slug: 'bug-prevention'"); + }); + + it.each([ + ["Project's docs", String.raw`title: 'Project\'s docs'`], + [String.raw`C:\Users\test`, String.raw`title: 'C:\\Users\\test'`], + ['Line one\nLine two', String.raw`title: 'Line one\nLine two'`], + ])('should escape %j in category title', (title, expected) => { + const plugin: PluginCodegenResult = { + ...ESLINT_PLUGIN, + categories: [ + { + slug: 'test', + title, + refs: [{ type: 'audit', plugin: 'p', slug: 's', weight: 1 }], + }, + ], + }; + expect(generateConfigSource([plugin], 'ts')).toContain(expected); + }); + + it('should include description and docsUrl when provided', () => { + const plugin: PluginCodegenResult = { + ...ESLINT_PLUGIN, + categories: [ + { + slug: 'perf', + title: 'Performance', + description: 'Measures runtime performance.', + docsUrl: 'https://example.com/perf', + refs: [{ type: 'audit', plugin: 'perf', slug: 'lcp', weight: 1 }], + }, + ], + }; + const source = generateConfigSource([plugin], 'ts'); + expect(source).toContain("description: 'Measures runtime performance.'"); + expect(source).toContain("docsUrl: 'https://example.com/perf'"); + }); + + it('should merge categories with same slug from different plugins', () => { + const ref = (plugin: string, slug: string) => ({ + type: 'group' as const, + plugin, + slug, + weight: 1, + }); + const source = generateConfigSource( + [ + { + ...ESLINT_PLUGIN, + categories: [ + { + slug: 'bugs', + title: 'Bugs', + refs: [ref('eslint', 'problems')], + }, + ], + }, + { + ...ESLINT_PLUGIN, + categories: [ + { slug: 'bugs', title: 'Bugs', refs: [ref('ts', 'errors')] }, + ], + }, + ], + 'ts', + ); + expect(source.match(/slug: 'bugs'/g)).toHaveLength(1); + expect(source).toContain("plugin: 'eslint'"); + expect(source).toContain("plugin: 'ts'"); + }); + }); }); describe('generatePresetSource', () => { @@ -243,6 +411,43 @@ describe('generatePresetSource', () => { " `); }); + + it('should include categories in TS preset source', () => { + expect(generatePresetSource([ESLINT_PLUGIN_WITH_CATEGORIES], 'ts')) + .toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + /** + * Creates a Code PushUp config for a project. + * @param project Project name + */ + export async function createConfig(project: string): Promise { + return { + plugins: [ + await eslintPlugin({ patterns: '.' }), + ], + categories: [ + { + slug: 'bug-prevention', + title: 'Bug prevention', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + ], + }, + { + slug: 'code-style', + title: 'Code style', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }, + ], + }, + ], + }; + } + " + `); + }); }); describe('generateProjectSource', () => { diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 0124063fe..aaca446b0 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,4 +1,4 @@ -import type { PluginMeta } from '@code-pushup/models'; +import type { CategoryConfig, PluginMeta } from '@code-pushup/models'; import type { MonorepoTool } from '@code-pushup/utils'; export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const; @@ -65,7 +65,7 @@ export type ImportDeclarationStructure = { export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; pluginInit: string; - // TODO: add categories support (categoryRefs for generated categories array) + categories?: CategoryConfig[]; }; export type ScopedPluginResult = { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 62c8401f8..f7d1ef1ec 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -93,7 +93,7 @@ export { } from './lib/guards.js'; export { interpolate } from './lib/interpolate.js'; export { Logger, logger } from './lib/logger.js'; -export { mergeConfigs } from './lib/merge-configs.js'; +export { mergeCategoriesBySlug, mergeConfigs } from './lib/merge-configs.js'; export { loadNxProjectGraph } from './lib/nx.js'; export { addIndex, diff --git a/packages/utils/src/lib/merge-configs.ts b/packages/utils/src/lib/merge-configs.ts index 61d5331c7..a9794bd64 100644 --- a/packages/utils/src/lib/merge-configs.ts +++ b/packages/utils/src/lib/merge-configs.ts @@ -65,6 +65,35 @@ function mergeCategories( return { categories: [...mergedMap.values()] }; } +/** Deduplicates categories that share the same slug. */ +export function mergeCategoriesBySlug( + categories: CategoryConfig[], +): CategoryConfig[] { + const map = categories.reduce((acc, category) => { + const existing = acc.get(category.slug); + acc.set( + category.slug, + existing + ? { + slug: existing.slug, + title: existing.title, + description: mergeDescriptions( + existing.description, + category.description, + ), + docsUrl: existing.docsUrl ?? category.docsUrl, + refs: mergeByUniqueCategoryRefCombination( + existing.refs, + category.refs, + ), + } + : category, + ); + return acc; + }, new Map()); + return [...map.values()]; +} + function mergePlugins( a: PluginConfig[] | undefined, b: PluginConfig[] | undefined, @@ -150,3 +179,24 @@ function mergeUpload( return { upload: b }; } } + +function toSentence(text: string): string { + const trimmed = text.trimEnd(); + if (trimmed.endsWith('.') || trimmed.endsWith('!') || trimmed.endsWith('?')) { + return trimmed; + } + return `${trimmed}.`; +} + +function mergeDescriptions( + a: string | undefined, + b: string | undefined, +): string | undefined { + if (!a) { + return b; + } + if (!b || a === b) { + return a; + } + return `${toSentence(a)} ${toSentence(b)}`; +} diff --git a/packages/utils/src/lib/merge-configs.unit.test.ts b/packages/utils/src/lib/merge-configs.unit.test.ts index 15c96b8d1..06aff873d 100644 --- a/packages/utils/src/lib/merge-configs.unit.test.ts +++ b/packages/utils/src/lib/merge-configs.unit.test.ts @@ -1,5 +1,9 @@ -import type { CoreConfig, PluginConfig } from '@code-pushup/models'; -import { mergeConfigs } from './merge-configs.js'; +import type { + CategoryConfig, + CoreConfig, + PluginConfig, +} from '@code-pushup/models'; +import { mergeCategoriesBySlug, mergeConfigs } from './merge-configs.js'; const MOCK_CONFIG_PERSIST = { persist: { @@ -328,3 +332,142 @@ describe('mergeObjects', () => { }); }); }); + +describe('mergeCategoriesBySlug', () => { + it('should return categories unchanged when no duplicates', () => { + const categories: CategoryConfig[] = [ + { slug: 'bug-prevention', title: 'Bug prevention', refs: [] }, + { slug: 'code-style', title: 'Code style', refs: [] }, + ]; + expect(mergeCategoriesBySlug(categories)).toEqual(categories); + }); + + it('should merge duplicate slugs — first title wins, refs concatenated', () => { + expect( + mergeCategoriesBySlug([ + { + slug: 'bug-prevention', + title: 'Bug prevention', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + ], + }, + { + slug: 'bug-prevention', + title: 'Bug detection', + refs: [ + { + type: 'group', + plugin: 'basic-plugin', + slug: 'problems', + weight: 1, + }, + ], + }, + ]), + ).toEqual([ + { + slug: 'bug-prevention', + title: 'Bug prevention', + refs: [ + { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }, + { + type: 'group', + plugin: 'basic-plugin', + slug: 'problems', + weight: 1, + }, + ], + }, + ]); + }); + + it('should join different descriptions as sentences', () => { + expect( + mergeCategoriesBySlug([ + { + slug: 'bug-prevention', + title: 'Bug prevention', + description: 'Catches common bugs', + refs: [], + }, + { + slug: 'bug-prevention', + title: 'Bug prevention', + description: 'Enforces type safety.', + refs: [], + }, + ]), + ).toContainEqual( + expect.objectContaining({ + description: 'Catches common bugs. Enforces type safety.', + }), + ); + }); + + it('should not duplicate identical descriptions', () => { + expect( + mergeCategoriesBySlug([ + { + slug: 'code-style', + title: 'Code style', + description: 'Consistent formatting.', + refs: [], + }, + { + slug: 'code-style', + title: 'Code style', + description: 'Consistent formatting.', + refs: [], + }, + ]), + ).toContainEqual( + expect.objectContaining({ description: 'Consistent formatting.' }), + ); + }); + + it('should use first non-empty docsUrl', () => { + expect( + mergeCategoriesBySlug([ + { + slug: 'bug-prevention', + title: 'Bug prevention', + docsUrl: 'https://eslint.org/rules', + refs: [], + }, + { + slug: 'bug-prevention', + title: 'Bug prevention', + docsUrl: 'https://typescript-eslint.io/rules', + refs: [], + }, + ]), + ).toContainEqual( + expect.objectContaining({ docsUrl: 'https://eslint.org/rules' }), + ); + }); + + it('should fall back to second value when first is missing', () => { + expect( + mergeCategoriesBySlug([ + { slug: 'code-style', title: 'Code style', refs: [] }, + { + slug: 'code-style', + title: 'Code style', + docsUrl: 'https://eslint.org/rules', + description: 'Consistent formatting.', + refs: [], + }, + ]), + ).toContainEqual( + expect.objectContaining({ + docsUrl: 'https://eslint.org/rules', + description: 'Consistent formatting.', + }), + ); + }); + + it('should return empty array for empty input', () => { + expect(mergeCategoriesBySlug([])).toEqual([]); + }); +});