Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion packages/create-cli/src/lib/setup/codegen.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}'`;
}
205 changes: 205 additions & 0 deletions packages/create-cli/src/lib/setup/codegen.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CategoryConfig } from '@code-pushup/models';
import {
computeRelativePresetImport,
generateConfigSource,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<CoreConfig> {
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', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-cli/src/lib/setup/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions packages/utils/src/lib/merge-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CategoryConfig>());
return [...map.values()];
}

function mergePlugins(
a: PluginConfig[] | undefined,
b: PluginConfig[] | undefined,
Expand Down Expand Up @@ -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)}`;
}
Loading
Loading