diff --git a/.github/workflows/code-pushup.yml b/.github/workflows/code-pushup.yml index cc1351476..9f0a19ec5 100644 --- a/.github/workflows/code-pushup.yml +++ b/.github/workflows/code-pushup.yml @@ -36,4 +36,4 @@ jobs: - name: Run Code PushUp action uses: code-pushup/github-action@v0 with: - bin: npx nx code-pushup -- + bin: npx nx code-pushup -- --no-progress diff --git a/code-pushup.config.ts b/code-pushup.config.ts index 9af68c6fb..c92bf4219 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -6,6 +6,7 @@ import { jsDocsCoreConfig, jsPackagesCoreConfig, lighthouseCoreConfig, + typescriptPluginConfigNx, } from './code-pushup.preset.js'; import type { CoreConfig } from './packages/models/src/index.js'; import { mergeConfigs } from './packages/utils/src/index.js'; @@ -39,6 +40,10 @@ export default mergeConfigs( await lighthouseCoreConfig( 'https://github.com/code-pushup/cli?tab=readme-ov-file#code-pushup-cli/', ), + await typescriptPluginConfigNx({ + tsconfig: + 'packages/plugin-typescript/mocks/fixtures/basic-setup/tsconfig.json', + }), await eslintCoreConfigNx(), jsDocsCoreConfig([ 'packages/**/src/**/*.ts', diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 9b9c462f9..ea8b692ce 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -21,6 +21,11 @@ import { filterGroupsByOnlyAudits } from './packages/plugin-jsdocs/src/lib/utils import lighthousePlugin, { lighthouseGroupRef, } from './packages/plugin-lighthouse/src/index.js'; +import { + type TypescriptPluginOptions, + getCategories, + typescriptPlugin, +} from './packages/plugin-typescript/src/index.js'; export const jsPackagesCategories: CategoryConfig[] = [ { @@ -168,6 +173,13 @@ export const eslintCoreConfigNx = async ( }; }; +export const typescriptPluginConfigNx = async ( + options?: TypescriptPluginOptions, +): Promise => ({ + plugins: [await typescriptPlugin(options)], + categories: getCategories(), +}); + export const coverageCoreConfigNx = async ( projectName?: string, ): Promise => { diff --git a/e2e/plugin-typescript-e2e/eslint.config.js b/e2e/plugin-typescript-e2e/eslint.config.js new file mode 100644 index 000000000..2656b27cb --- /dev/null +++ b/e2e/plugin-typescript-e2e/eslint.config.js @@ -0,0 +1,12 @@ +import tseslint from 'typescript-eslint'; +import baseConfig from '../../eslint.config.js'; + +export default tseslint.config(...baseConfig, { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/code-pushup.config.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/code-pushup.config.ts new file mode 100644 index 000000000..65a30130b --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/code-pushup.config.ts @@ -0,0 +1,19 @@ +import type { CoreConfig } from '@code-pushup/models'; +import {getCategoryRefsFromGroups, typescriptPlugin} from "@code-pushup/typescript-plugin"; + +export default { + plugins: [ + await typescriptPlugin({ + tsConfigPath: 'tsconfig.json', + }), + ], + categories: [ + { + slug: 'typescript-quality', + title: 'Typescript', + refs: await getCategoryRefsFromGroups({ + tsConfigPath: 'tsconfig.json', + }) + } + ], +} satisfies CoreConfig; diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/exclude/utils.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/exclude/utils.ts new file mode 100644 index 000000000..20f47ca5d --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/exclude/utils.ts @@ -0,0 +1,3 @@ +export function test() { + return 'test'; +} diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/1-syntax-errors.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/1-syntax-errors.ts new file mode 100644 index 000000000..5ba0acd92 --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/1-syntax-errors.ts @@ -0,0 +1 @@ +const a = { ; // Error: TS1136: Property assignment expected diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/2-semantic-errors.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/2-semantic-errors.ts new file mode 100644 index 000000000..f29009cea --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/2-semantic-errors.ts @@ -0,0 +1,7 @@ +// 2683 - NoImplicitThis: 'this' implicitly has type 'any'. +function noImplicitThisTS2683() { + console.log(this.value); // Error 2683 +} + +// 2531 - StrictNullChecks: Object is possibly 'null'. +const strictNullChecksTS2531: string = null; diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/4-languale-service.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/4-languale-service.ts new file mode 100644 index 000000000..444811dfb --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/4-languale-service.ts @@ -0,0 +1,6 @@ +class Standalone { + override method() { // Error: TS4114 - 'override' modifier can only be used in a class derived from a base class. + console.log("Standalone method"); + } +} +const s = Standalone; diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/6-configuration-errors.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/6-configuration-errors.ts new file mode 100644 index 000000000..714b2b4bc --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/6-configuration-errors.ts @@ -0,0 +1 @@ +import { test } from '../exclude/utils'; // TS6059:: File 'exclude/utils.ts' is not under 'rootDir' '.../configuration-errors'. 'rootDir' is expected to contain all source files. diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/tsconfig.json b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/tsconfig.json new file mode 100644 index 000000000..63e0c1934 --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "target": "ES6", + "module": "CommonJS", + "strict": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts"], +} diff --git a/e2e/plugin-typescript-e2e/project.json b/e2e/plugin-typescript-e2e/project.json new file mode 100644 index 000000000..4ec3f7c8a --- /dev/null +++ b/e2e/plugin-typescript-e2e/project.json @@ -0,0 +1,23 @@ +{ + "name": "plugin-typescript-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/plugin-typescript-e2e/src", + "projectType": "application", + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["e2e/plugin-typescript-e2e/**/*.ts"] + } + }, + "e2e": { + "executor": "@nx/vite:test", + "options": { + "configFile": "e2e/plugin-typescript-e2e/vite.config.e2e.ts" + } + } + }, + "implicitDependencies": ["cli", "plugin-typescript"], + "tags": ["scope:plugin", "type:e2e"] +} diff --git a/e2e/plugin-typescript-e2e/tests/__snapshots__/typescript-plugin-json-report.json b/e2e/plugin-typescript-e2e/tests/__snapshots__/typescript-plugin-json-report.json new file mode 100644 index 000000000..e45d4905b --- /dev/null +++ b/e2e/plugin-typescript-e2e/tests/__snapshots__/typescript-plugin-json-report.json @@ -0,0 +1,219 @@ +{ + "categories": [ + { + "refs": [ + { + "plugin": "typescript", + "slug": "problems-group", + "type": "group", + "weight": 1, + }, + { + "plugin": "typescript", + "slug": "ts-configuration-group", + "type": "group", + "weight": 1, + }, + { + "plugin": "typescript", + "slug": "miscellaneous-group", + "type": "group", + "weight": 1, + }, + ], + "slug": "typescript-quality", + "title": "Typescript", + }, + ], + "packageName": "@code-pushup/core", + "plugins": [ + { + "audits": [ + { + "description": "Errors that occur during parsing and lexing of TypeScript source code", + "details": { + "issues": [ + { + "message": "TS1136: Property assignment expected.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/1-syntax-errors.ts", + "position": { + "startLine": 1, + }, + }, + }, + ], + }, + "score": 0, + "slug": "syntax-errors", + "title": "Syntax-Errors", + "value": 1, + }, + { + "description": "Errors that occur during type checking and type inference", + "details": { + "issues": [ + { + "message": "TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/2-semantic-errors.ts", + "position": { + "startLine": 3, + }, + }, + }, + { + "message": "TS2322: Type 'null' is not assignable to type 'string'.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/2-semantic-errors.ts", + "position": { + "startLine": 7, + }, + }, + }, + ], + }, + "score": 0, + "slug": "semantic-errors", + "title": "Semantic-Errors", + "value": 2, + }, + { + "description": "Errors that occur during TypeScript language service operations", + "details": { + "issues": [ + { + "message": "TS4112: This member cannot have an 'override' modifier because its containing class 'Standalone' does not extend another class.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/4-languale-service.ts", + "position": { + "startLine": 2, + }, + }, + }, + ], + }, + "score": 0, + "slug": "declaration-and-language-service-errors", + "title": "Declaration-And-Language-Service-Errors", + "value": 1, + }, + { + "description": "Errors that occur during TypeScript internal operations", + "details": { + "issues": [], + }, + "score": 1, + "slug": "internal-errors", + "title": "Internal-Errors", + "value": 0, + }, + { + "description": "Errors that occur when parsing TypeScript configuration files", + "details": { + "issues": [ + { + "message": "TS6059: File '/Users/michael_hladky/WebstormProjects/quality-metrics-cli/tmp/e2e/plugin-typescript-e2e/exclude/utils.ts' is not under 'rootDir' 'src'. 'rootDir' is expected to contain all source files.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/6-configuration-errors.ts", + "position": { + "startLine": 1, + }, + }, + }, + ], + }, + "score": 0, + "slug": "configuration-errors", + "title": "Configuration-Errors", + "value": 1, + }, + { + "description": "Errors related to no implicit any compiler option", + "details": { + "issues": [], + }, + "score": 1, + "slug": "no-implicit-any-errors", + "title": "No-Implicit-Any-Errors", + "value": 0, + }, + { + "description": "Errors that do not match any known TypeScript error code", + "details": { + "issues": [], + }, + "score": 1, + "slug": "unknown-codes", + "title": "Unknown-Codes", + "value": 0, + }, + ], + "date": "2025-01-10T17:22:29.075Z", + "description": "Official Code PushUp Typescript plugin.", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/typescript-plugin/", + "duration": 2091, + "groups": [ + { + "description": "Syntax, semantic, and internal compiler errors are critical for identifying and preventing bugs.", + "refs": [ + { + "slug": "syntax-errors", + "weight": 1, + }, + { + "slug": "semantic-errors", + "weight": 1, + }, + { + "slug": "internal-errors", + "weight": 1, + }, + { + "slug": "no-implicit-any-errors", + "weight": 1, + }, + ], + "slug": "problems-group", + "title": "Problems", + }, + { + "description": "TypeScript configuration and options errors ensure correct project setup, reducing risks from misconfiguration.", + "refs": [ + { + "slug": "configuration-errors", + "weight": 1, + }, + ], + "slug": "ts-configuration-group", + "title": "Configuration", + }, + { + "description": "Errors that do not bring any specific value to the developer, but are still useful to know.", + "refs": [ + { + "slug": "unknown-codes", + "weight": 1, + }, + { + "slug": "declaration-and-language-service-errors", + "weight": 1, + }, + ], + "slug": "miscellaneous-group", + "title": "Miscellaneous", + }, + ], + "icon": "typescript", + "packageName": "@code-pushup/typescript-plugin", + "slug": "typescript", + "title": "Typescript", + "version": "0.57.0", + }, + ], +} \ No newline at end of file diff --git a/e2e/plugin-typescript-e2e/tests/collect.e2e.test.ts b/e2e/plugin-typescript-e2e/tests/collect.e2e.test.ts new file mode 100644 index 000000000..87fd1dd2b --- /dev/null +++ b/e2e/plugin-typescript-e2e/tests/collect.e2e.test.ts @@ -0,0 +1,89 @@ +import { cp } from 'node:fs/promises'; +// eslint-disable-next-line unicorn/import-style +import path, { join } from 'node:path'; +import { afterAll, beforeAll, expect } from 'vitest'; +import { type Report, reportSchema } from '@code-pushup/models'; +import { nxTargetProject } from '@code-pushup/test-nx-utils'; +import { teardownTestFolder } from '@code-pushup/test-setup'; +import { + E2E_ENVIRONMENTS_DIR, + TEST_OUTPUT_DIR, + omitVariableReportData, + osAgnosticPath, + removeColorCodes, +} from '@code-pushup/test-utils'; +import { executeProcess, readJsonFile } from '@code-pushup/utils'; + +describe('PLUGIN collect report with typescript-plugin NPM package', () => { + const envRoot = join(E2E_ENVIRONMENTS_DIR, nxTargetProject()); + const distRoot = join(envRoot, TEST_OUTPUT_DIR); + + const fixturesDir = join( + 'e2e', + nxTargetProject(), + 'mocks', + 'fixtures', + 'default-setup', + ); + + beforeAll(async () => { + await cp(fixturesDir, envRoot, { recursive: true }); + }); + + afterAll(async () => { + await teardownTestFolder(distRoot); + }); + + it('should run plugin over CLI and creates report.json', async () => { + const outputDir = join( + path.relative(envRoot, distRoot), + 'create-report', + '.code-pushup', + ); + + const { code, stdout } = await executeProcess({ + command: 'npx', + // verbose exposes audits with perfect scores that are hidden in the default stdout + args: [ + '@code-pushup/cli', + 'collect', + '--no-progress', + '--verbose', + `--persist.outputDir=${outputDir}`, + ], + cwd: envRoot, + }); + + expect(code).toBe(0); + const cleanStdout = removeColorCodes(stdout); + + // @TODO should be 1 test failing => /● NoImplicitAny\s+1/ + expect(cleanStdout).toMatch(/● Configuration-Errors\s+\d+/); + + const reportJson = await readJsonFile( + join(envRoot, outputDir, 'report.json'), + ); + expect(() => reportSchema.parse(reportJson)).not.toThrow(); + expect({ + ...omitVariableReportData(reportJson, { omitAuditData: false }), + plugins: reportJson.plugins.map(plugin => ({ + ...plugin, + audits: plugin.audits.map(audit => ({ + ...audit, + details: { + ...audit.details, + issues: (audit?.details?.issues ?? []).map(issue => ({ + ...issue, + source: { + ...issue.source, + ...(issue?.source?.file + ? { file: osAgnosticPath(issue?.source?.file) } + : {}), + }, + })), + }, + })), + })), + }).toMatchFileSnapshot('__snapshots__/typescript-plugin-json-report.json'); + }); +}); diff --git a/e2e/plugin-typescript-e2e/tsconfig.json b/e2e/plugin-typescript-e2e/tsconfig.json new file mode 100644 index 000000000..f5a2f890a --- /dev/null +++ b/e2e/plugin-typescript-e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/e2e/plugin-typescript-e2e/tsconfig.test.json b/e2e/plugin-typescript-e2e/tsconfig.test.json new file mode 100644 index 000000000..10c7f79de --- /dev/null +++ b/e2e/plugin-typescript-e2e/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"], + "target": "ES2020" + }, + "include": [ + "vite.config.e2e.ts", + "tests/**/*.e2e.test.ts", + "tests/**/*.d.ts", + "mocks/**/*.ts" + ] +} diff --git a/e2e/plugin-typescript-e2e/vite.config.e2e.ts b/e2e/plugin-typescript-e2e/vite.config.e2e.ts new file mode 100644 index 000000000..e6b460427 --- /dev/null +++ b/e2e/plugin-typescript-e2e/vite.config.e2e.ts @@ -0,0 +1,26 @@ +/// +import { defineConfig } from 'vite'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-typescript-e2e', + test: { + reporters: ['basic'], + testTimeout: 120_000, + globals: true, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-typescript-e2e/e2e-tests', + exclude: ['mocks/**', '**/types.ts'], + }, + cache: { + dir: '../../node_modules/.vitest', + }, + environment: 'node', + include: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['../../testing/test-setup/src/lib/reset.mocks.ts'], + }, +}); diff --git a/packages/plugin-eslint/src/lib/runner.integration.test.ts b/packages/plugin-eslint/src/lib/runner.integration.test.ts index 8a58d60eb..984076a88 100644 --- a/packages/plugin-eslint/src/lib/runner.integration.test.ts +++ b/packages/plugin-eslint/src/lib/runner.integration.test.ts @@ -91,5 +91,5 @@ describe('executeRunner', () => { }, }), ); - }, 15_000); + }, 18_000); }); diff --git a/packages/plugin-typescript/README.md b/packages/plugin-typescript/README.md new file mode 100644 index 000000000..9bbda2949 --- /dev/null +++ b/packages/plugin-typescript/README.md @@ -0,0 +1,169 @@ +# @code-pushup/typescript-plugin + +[![npm](https://img.shields.io/npm/v/%40code-pushup%2Ftypescript-plugin.svg)](https://www.npmjs.com/package/@code-pushup/typescript-plugin) +[![downloads](https://img.shields.io/npm/dm/%40code-pushup%2Ftypescript-plugin)](https://npmtrends.com/@code-pushup/typescript-plugin) +[![dependencies](https://img.shields.io/librariesio/release/npm/%40code-pushup/typescript-plugin)](https://www.npmjs.com/package/@code-pushup/typescript-plugin?activeTab=dependencies) + +🕵️ **Code PushUp plugin for measuring TypeScript quality with compiler diagnostics.** 🔥 + +This plugin allows you to **incrementally adopting strict compilation flags in TypeScript projects**. +It analyzes your codebase using the TypeScript compiler to detect potential issues and configuration problems. + +TypeScript compiler diagnostics are mapped to Code PushUp audits in the following way: + +- `value`: The number of issues found for a specific TypeScript configuration option -> 3 +- `displayValue`: The number of issues found -> 3 issues +- `score`: Binary scoring - 1 if no issues are found, 0 if any issues exist +- Issues are mapped to audit details, containing: + - Source file location + - Error message from TypeScript compiler + - Code reference where the issue was found + +## Getting started + +1. If you haven't already, install [@code-pushup/cli](../cli/README.md) and create a configuration file. + +2. Install as a dev dependency with your package manager: + + ```sh + npm install --save-dev @code-pushup/typescript-plugin + ``` + + ```sh + yarn add --dev @code-pushup/typescript-plugin + ``` + + ```sh + pnpm add --save-dev @code-pushup/typescript-plugin + ``` + +3. Add this plugin to the `plugins` array in your Code PushUp CLI config file (e.g. `code-pushup.config.ts`). + +Define the ts config file used to compile your codebase. Based on those compiler options the plugin will generate audits. + +```ts +import typescriptPlugin from '@code-pushup/typescript-plugin'; + +export default { + // ... + plugins: [ + // ... + typescriptPlugin({ + tsConfigPath: './tsconfig.json', + onlyAudits: ['no-implicit-any'], + }), + ], +}; +``` + +4. Run the CLI with `npx code-pushup collect` and view or upload the report (refer to [CLI docs](../cli/README.md)). + +## About TypeScript checks + +The TypeScript plugin analyzes your codebase using the TypeScript compiler to identify potential issues and enforce best practices. +It helps ensure type safety and maintainability of your TypeScript code. + +The plugin provides multiple audits grouped into different sets: + +- Language and Environment - Configuration options for TypeScript language features and runtime environment, including decorators, JSX support, target ECMAScript version, and class field behaviors +- Interop Constraints - Settings that control how TypeScript interoperates with other JavaScript code, including module imports/exports and case sensitivity rules +- Watch Options - Configuration for TypeScript watch mode behavior, including file watching strategies and dependency tracking +- Project References - Options for managing TypeScript project references, composite projects, and build optimization settings +- Module Resolution - Settings that control how TypeScript finds and resolves module imports, including Node.js resolution, package.json exports/imports, and module syntax handling +- Type Checking Behavior - Configuration for TypeScript type checking strictness and error reporting, including property access rules and method override checking +- Control Flow Options - Settings that affect code flow analysis, including handling of unreachable code, unused labels, switch statements, and async/generator functions +- Strict Checks - Strict type checking options that enable additional compile-time verifications, including null checks, implicit any/this, and function type checking +- Build/Emit Options - Configuration options that control TypeScript output generation, including whether to emit files, how to handle comments and declarations, and settings for output optimization and compatibility helpers + +Each audit: + +- Checks for specific TypeScript compiler errors and warnings +- Provides a score based on the number of issues found +- Includes detailed error messages and locations + +Each set is also available as group in the plugin. See more under [Audits and Groups]() + +## Plugin architecture + +### Plugin configuration specification + +The plugin accepts the following parameters: + +| Option | Type | Default | Description | +| ------------ | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| tsConfigPath | string | `tsconfig.json` | A string that defines the path to your `tsconfig.json` file | +| onlyAudits | string[] | undefined | An array of audit slugs to specify which documentation types you want to measure. Only the specified audits will be included in the results | + +#### TsConfigPath + +Optional parameter. The `tsConfigPath` option accepts a string that defines the path to your config file and defaults to `tsconfig.json`. + +```js +typescriptPlugin({ + tsConfigPath: './tsconfig.json', +}); +``` + +#### OnlyAudits + +Optional parameter. The `onlyAudits` option allows you to specify which documentation types you want to measure. Only the specified audits will be included in the results. Example: + +```js +typescriptPlugin({ + onlyAudits: ['no-implicit-any'], +}); +``` + +### Optionally set up categories + +1. Reference audits (or groups) which you wish to include in custom categories (use `npx code-pushup print-config` to list audits and groups). + +Assign weights based on what influence each TypeScript checks should have on the overall category score (assign weight 0 to only include as extra info, without influencing category score). + +```ts +// ... +categories: [ + { + slug: 'typescript', + title: 'TypeScript', + refs: [ + { + type: 'audit', + plugin: 'typescript', + slug: 'no-implicit-any', + weight: 2, + }, + { + type: 'audit', + plugin: 'typescript', + slug: 'no-explicit-any', + weight: 1, + }, + // ... + ], + }, + // ... +]; +``` + +Also groups can be used: + +```ts +// ... +categories: [ + { + slug: 'typescript', + title: 'TypeScript', + refs: [ + { + slug: 'language-and-environment', + weight: 1, + type: 'group', + plugin: 'typescript', + }, + // ... + ], + }, + // ... +]; +``` diff --git a/packages/plugin-typescript/src/lib/schema.ts b/packages/plugin-typescript/src/lib/schema.ts new file mode 100644 index 000000000..54270bcc2 --- /dev/null +++ b/packages/plugin-typescript/src/lib/schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { AUDITS, DEFAULT_TS_CONFIG } from './constants.js'; +import type { AuditSlug } from './types.js'; + +const auditSlugs = AUDITS.map(({ slug }) => slug) as [ + AuditSlug, + ...AuditSlug[], +]; +export const typescriptPluginConfigSchema = z.object({ + tsconfig: z + .string({ + description: 'Path to the TsConfig', + }) + .default(DEFAULT_TS_CONFIG), + onlyAudits: z + .array(z.enum(auditSlugs), { + description: 'Array with specific TsCodes to measure', + }) + .optional(), +}); + +export type TypescriptPluginOptions = z.infer< + typeof typescriptPluginConfigSchema +>; diff --git a/packages/plugin-typescript/src/lib/schema.unit.test.ts b/packages/plugin-typescript/src/lib/schema.unit.test.ts new file mode 100644 index 000000000..fd09145f6 --- /dev/null +++ b/packages/plugin-typescript/src/lib/schema.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + type TypescriptPluginOptions, + typescriptPluginConfigSchema, +} from './schema.js'; + +describe('typescriptPluginConfigSchema', () => { + const tsConfigPath = 'tsconfig.json'; + + it('accepts a empty configuration', () => { + expect(() => typescriptPluginConfigSchema.parse({})).not.toThrow(); + }); + + it('accepts a configuration with tsConfigPath set', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsConfigPath, + } satisfies TypescriptPluginOptions), + ).not.toThrow(); + }); + + it('accepts a configuration with tsConfigPath and empty onlyAudits', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsConfigPath, + onlyAudits: [], + } satisfies TypescriptPluginOptions), + ).not.toThrow(); + }); + + it('accepts a configuration with tsConfigPath and full onlyAudits', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsConfigPath, + onlyAudits: [ + 'syntax-errors', + 'semantic-errors', + 'configuration-errors', + ], + } satisfies TypescriptPluginOptions), + ).not.toThrow(); + }); + + it('throws for invalid onlyAudits', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + onlyAudits: 123, + }), + ).toThrow('invalid_type'); + }); + + it('throws for invalid onlyAudits items', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsConfigPath, + onlyAudits: [123, true], + }), + ).toThrow('invalid_type'); + }); + + it('throws for unknown audit slug', () => { + expect( + () => + typescriptPluginConfigSchema.parse({ + tsConfigPath, + onlyAudits: ['unknown-audit'], + }), + // Message too large because enums validation + // eslint-disable-next-line vitest/require-to-throw-message + ).toThrow(); + }); +}); diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.ts b/packages/plugin-typescript/src/lib/typescript-plugin.ts new file mode 100644 index 000000000..acb5aef8c --- /dev/null +++ b/packages/plugin-typescript/src/lib/typescript-plugin.ts @@ -0,0 +1,57 @@ +import { createRequire } from 'node:module'; +import type { PluginConfig } from '@code-pushup/models'; +import { DEFAULT_TS_CONFIG, TYPESCRIPT_PLUGIN_SLUG } from './constants.js'; +import { createRunnerFunction } from './runner/runner.js'; +import type { DiagnosticsOptions } from './runner/ts-runner.js'; +import { typescriptPluginConfigSchema } from './schema.js'; +import type { AuditSlug } from './types.js'; +import { getAudits, getGroups, logSkippedAudits } from './utils.js'; + +const packageJson = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +export type FilterOptions = { onlyAudits?: AuditSlug[] | undefined }; +export type TypescriptPluginOptions = Partial & + FilterOptions; + +export async function typescriptPlugin( + options?: TypescriptPluginOptions, +): Promise { + const { tsconfig = DEFAULT_TS_CONFIG, onlyAudits } = parseOptions( + options ?? {}, + ); + + const filteredAudits = getAudits({ onlyAudits }); + const filteredGroups = getGroups({ onlyAudits }); + + logSkippedAudits(filteredAudits); + + return { + slug: TYPESCRIPT_PLUGIN_SLUG, + packageName: packageJson.name, + version: packageJson.version, + title: 'Typescript', + description: 'Official Code PushUp Typescript plugin.', + docsUrl: 'https://www.npmjs.com/package/@code-pushup/typescript-plugin/', + icon: 'typescript', + audits: filteredAudits, + groups: filteredGroups, + runner: createRunnerFunction({ + tsconfig, + expectedAudits: filteredAudits, + }), + }; +} + +function parseOptions( + tsPluginOptions: TypescriptPluginOptions, +): TypescriptPluginOptions { + try { + return typescriptPluginConfigSchema.parse(tsPluginOptions); + } catch (error) { + throw new Error( + `Error parsing TypeScript Plugin options: ${(error as Error).message}`, + ); + } +} diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts new file mode 100644 index 000000000..8169eafab --- /dev/null +++ b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts @@ -0,0 +1,31 @@ +import { expect } from 'vitest'; +import { pluginConfigSchema } from '@code-pushup/models'; +import { AUDITS, GROUPS } from './constants.js'; +import { typescriptPlugin } from './typescript-plugin.js'; + +describe('typescriptPlugin-config-object', () => { + it('should create valid plugin config without options', async () => { + const pluginConfig = await typescriptPlugin(); + + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + + const { audits, groups } = pluginConfig; + expect(audits).toHaveLength(AUDITS.length); + expect(groups).toBeDefined(); + expect(groups!).toHaveLength(GROUPS.length); + }); + + it('should create valid plugin config', async () => { + const pluginConfig = await typescriptPlugin({ + tsConfigPath: 'mocked-away/tsconfig.json', + onlyAudits: ['syntax-errors', 'semantic-errors', 'configuration-errors'], + }); + + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + + const { audits, groups } = pluginConfig; + expect(audits).toHaveLength(3); + expect(groups).toBeDefined(); + expect(groups!).toHaveLength(2); + }); +}); diff --git a/packages/utils/src/lib/string.ts b/packages/utils/src/lib/string.ts new file mode 100644 index 000000000..516c12289 --- /dev/null +++ b/packages/utils/src/lib/string.ts @@ -0,0 +1,58 @@ +import type { CamelCaseToKebabCase } from './types'; + +/** + * Converts a kebab-case string to camelCase. + * @param string - The kebab-case string to convert. + * @returns The camelCase string. + */ +export function kebabCaseToCamelCase(string: string) { + return string + .split('-') + .map((segment, index) => + index === 0 + ? segment + : segment.charAt(0).toUpperCase() + segment.slice(1), + ) + .join(''); +} + +/** + * Converts a camelCase string to kebab-case. + * @param string - The camelCase string to convert. + * @returns The kebab-case string. + */ +export function camelCaseToKebabCase( + string: T, +): CamelCaseToKebabCase { + return string + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase + .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase + .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens + .toLowerCase() as CamelCaseToKebabCase; +} + +/** + * Formats a slug to a readable title. + * @param slug - The slug to format. + * @returns The formatted title. + */ +export function kebabCaseToSentence(slug: string = '') { + return slug + .replace(/-/g, ' ') + .replace(/\b\w/g, letter => letter.toUpperCase()); +} + +/** + * Formats a slug to a readable title. + * @param slug - The slug to format. + * @returns The formatted title. + */ +export function camelCaseToSentence(slug: string = '') { + return slug + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // Split between uppercase followed by uppercase+lowercase + .replace(/([a-z])([A-Z])/g, '$1-$2') // Split between lowercase followed by uppercase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // Additional split for consecutive uppercase + .replace(/[\s_]+/g, ' ') // Replace spaces and underscores with hyphens + .replace(/\b\w/g, letter => letter.toUpperCase()); +} diff --git a/packages/utils/src/lib/string.unit.test.ts b/packages/utils/src/lib/string.unit.test.ts new file mode 100644 index 000000000..d7100c984 --- /dev/null +++ b/packages/utils/src/lib/string.unit.test.ts @@ -0,0 +1,81 @@ +import { + camelCaseToKebabCase, + kebabCaseToCamelCase, + kebabCaseToSentence, +} from './string.js'; + +describe('kebabCaseToCamelCase', () => { + it('should convert simple kebab-case to camelCase', () => { + expect(kebabCaseToCamelCase('hello-world')).toBe('helloWorld'); + }); + + it('should handle multiple hyphens', () => { + expect(kebabCaseToCamelCase('this-is-a-long-string')).toBe( + 'thisIsALongString', + ); + }); + + it('should preserve numbers', () => { + expect(kebabCaseToCamelCase('user-123-test')).toBe('user123Test'); + }); + + it('should handle single word', () => { + expect(kebabCaseToCamelCase('hello')).toBe('hello'); + }); + + it('should handle empty string', () => { + expect(kebabCaseToCamelCase('')).toBe(''); + }); +}); + +describe('camelCaseToKebabCase', () => { + it('should convert simple camelCase to kebab-case', () => { + expect(camelCaseToKebabCase('helloWorld')).toBe('hello-world'); + }); + + it('should handle multiple capital letters', () => { + expect(camelCaseToKebabCase('thisIsALongString')).toBe( + 'this-is-a-long-string', + ); + }); + + it('should handle consecutive capital letters', () => { + expect(camelCaseToKebabCase('myXMLParser')).toBe('my-xml-parser'); + }); + + it('should handle spaces and underscores', () => { + expect(camelCaseToKebabCase('hello_world test')).toBe('hello-world-test'); + }); + + it('should handle single word', () => { + expect(camelCaseToKebabCase('hello')).toBe('hello'); + }); + + it('should handle empty string', () => { + expect(camelCaseToKebabCase('')).toBe(''); + }); +}); + +describe('kebabCaseToSentence', () => { + it('should convert simple slug to title case', () => { + expect(kebabCaseToSentence('hello-world')).toBe('Hello World'); + }); + + it('should handle multiple hyphens', () => { + expect(kebabCaseToSentence('this-is-a-title')).toBe('This Is A Title'); + }); + + it('should handle empty string', () => { + expect(kebabCaseToSentence()).toBe(''); + }); + + it('should handle single word', () => { + expect(kebabCaseToSentence('hello')).toBe('Hello'); + }); + + it('should handle numbers in slug', () => { + expect(kebabCaseToSentence('chapter-1-introduction')).toBe( + 'Chapter 1 Introduction', + ); + }); +});