diff --git a/code-pushup.config.ts b/code-pushup.config.ts index bd089d884..b48295452 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -2,6 +2,7 @@ import 'dotenv/config'; import { z } from 'zod'; import { coverageCoreConfigNx, + docCoverageCoreConfig, eslintCoreConfigNx, jsPackagesCoreConfig, lighthouseCoreConfig, @@ -39,4 +40,14 @@ export default mergeConfigs( 'https://github.com/code-pushup/cli?tab=readme-ov-file#code-pushup-cli/', ), await eslintCoreConfigNx(), + await docCoverageCoreConfig({ + sourceGlob: [ + 'packages/**/src/**/*.ts', + '!packages/**/node_modules', + '!packages/**/{mocks,mock}', + '!**/*.{spec,test}.ts', + '!**/implementation/**', + '!**/internal/**', + ], + }), ); diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 74e6b51ce..c7f907f03 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -5,6 +5,14 @@ import type { import coveragePlugin, { getNxCoveragePaths, } from './packages/plugin-coverage/src/index.js'; +import docCoveragePlugin, { + DocCoveragePluginConfig, +} from './packages/plugin-doc-coverage/src/index.js'; +import { + PLUGIN_SLUG, + groups, +} from './packages/plugin-doc-coverage/src/lib/constants.js'; +import { filterGroupsByOnlyAudits } from './packages/plugin-doc-coverage/src/lib/utils.js'; import eslintPlugin, { eslintConfigFromAllNxProjects, eslintConfigFromNxProject, @@ -82,6 +90,24 @@ export const eslintCategories: CategoryConfig[] = [ }, ]; +export function getDocCoverageCategories( + config: DocCoveragePluginConfig, +): CategoryConfig[] { + return [ + { + slug: 'doc-coverage-cat', + title: 'Documentation coverage', + description: 'Measures how much of your code is **documented**.', + refs: filterGroupsByOnlyAudits(groups, config).map(group => ({ + weight: 1, + type: 'group', + plugin: PLUGIN_SLUG, + slug: group.slug, + })), + }, + ]; +} + export const coverageCategories: CategoryConfig[] = [ { slug: 'code-coverage', @@ -114,6 +140,15 @@ export const lighthouseCoreConfig = async ( }; }; +export const docCoverageCoreConfig = async ( + config: DocCoveragePluginConfig, +): Promise => { + return { + plugins: [docCoveragePlugin(config)], + categories: getDocCoverageCategories(config), + }; +}; + export const eslintCoreConfigNx = async ( projectName?: string, ): Promise => { diff --git a/package-lock.json b/package-lock.json index 0459e1180..206de309d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "parse-lcov": "^1.0.4", "semver": "^7.6.3", "simple-git": "^3.26.0", + "ts-morph": "^24.0.0", "tslib": "^2.6.2", "vscode-material-icons": "^0.1.1", "yaml": "^2.5.1", @@ -7145,6 +7146,30 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -10798,6 +10823,11 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==" + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -21674,6 +21704,11 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -24633,6 +24668,42 @@ "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", "dev": true }, + "node_modules/tinyglobby": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", + "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==", + "dependencies": { + "fdir": "^6.4.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", + "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", @@ -24856,6 +24927,15 @@ "typescript": ">=4.0.0" } }, + "node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", diff --git a/package.json b/package.json index 2782021cf..6366530e8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "parse-lcov": "^1.0.4", "semver": "^7.6.3", "simple-git": "^3.26.0", + "ts-morph": "^24.0.0", "tslib": "^2.6.2", "vscode-material-icons": "^0.1.1", "yaml": "^2.5.1", diff --git a/packages/plugin-doc-coverage/README.md b/packages/plugin-doc-coverage/README.md new file mode 100644 index 000000000..d41ba4576 --- /dev/null +++ b/packages/plugin-doc-coverage/README.md @@ -0,0 +1,243 @@ +# @code-pushup/doc-coverage-plugin + +[![npm](https://img.shields.io/npm/v/%40code-pushup%2Fdoc-coverage-plugin.svg)](https://www.npmjs.com/package/@code-pushup/doc-coverage-plugin) +[![downloads](https://img.shields.io/npm/dm/%40code-pushup%2Fdoc-coverage-plugin)](https://npmtrends.com/@code-pushup/doc-coverage-plugin) +[![dependencies](https://img.shields.io/librariesio/release/npm/%40code-pushup%2Fdoc-coverage-plugin)](https://www.npmjs.com/package/@code-pushup/doc-coverage-plugin?activeTab=dependencies) + +📚 **Code PushUp plugin for tracking documentation coverage.** 📝 + +This plugin allows you to measure and track documentation coverage in your TypeScript/JavaScript project. +It analyzes your codebase and checks for documentation on different code elements like classes, functions, interfaces, types, and variables. + +Measured documentation types are mapped to Code PushUp audits in the following way: + +- `value`: The value is the number of undocumented nodes -> 4 +- `displayValue`: `${value} undocumented ${type}` -> 4 undocumented functions +- `score`: 0.5 -> total nodes 8 undocumented 4 -> 8/4 +- The score is value converted to 0-1 range +- Missing documentation is mapped to issues in the audit details (undocumented classes, functions, interfaces, etc.) + +## 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/doc-coverage-plugin + ``` + + ```sh + yarn add --dev @code-pushup/doc-coverage-plugin + ``` + + ```sh + pnpm add --save-dev @code-pushup/doc-coverage-plugin + ``` + +3. Add this plugin to the `plugins` array in your Code PushUp CLI config file (e.g. `code-pushup.config.ts`). + + ```js + import docCoveragePlugin from '@code-pushup/doc-coverage-plugin'; + + export default { + // ... + plugins: [ + // ... + docCoveragePlugin({ + sourceGlob: ['**/*.ts'], + }), + ], + }; + ``` + +4. (Optional) Reference individual audits or the provided plugin group 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 documentation type should have on the overall category score (assign weight 0 to only include as extra info, without influencing category score). + + ```js + export default { + // ... + categories: [ + { + slug: 'documentation', + title: 'Documentation', + refs: [ + { + type: 'group', + plugin: 'doc-coverage', + slug: 'doc-coverage', + weight: 1, + }, + // ... + ], + }, + // ... + ], + }; + ``` + +5. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)). + +## About documentation coverage + +Documentation coverage is a metric that indicates what percentage of your code elements have proper documentation. It helps ensure your codebase is well-documented and maintainable. + +The plugin provides multiple audits, one for each documentation type (classes, functions, interfaces, etc.), and groups them together for an overall documentation coverage measurement. Each audit: + +- Measures the documentation coverage for its specific type (e.g., classes, functions) +- Provides a score based on the percentage of documented elements +- Includes details about which elements are missing documentation + +These audits are grouped together to provide a comprehensive view of your codebase's documentation status. You can use either: + +- The complete group of audits for overall documentation coverage +- Individual audits to focus on specific documentation types + +## Plugin architecture + +### Plugin configuration specification + +The plugin accepts the following parameters: + +#### SourceGlob + +Required parameter. The `sourceGlob` option accepts an array of strings that define patterns to include or exclude files. You can use glob patterns to match files and the `!` symbol to exclude specific patterns. Example: + +```js +docCoveragePlugin({ + sourceGlob: [ + 'src/**/*.ts', // include all TypeScript files in src + '!src/**/*.{spec,test}.ts', // exclude test files + '!src/**/testing/**/*.ts' // exclude testing utilities + ], +}), +``` + +#### 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 +docCoveragePlugin({ + sourceGlob: ['src/**/*.ts'], + onlyAudits: [ + 'classes-coverage', + 'functions-coverage' + ] // Only measure documentation for classes and functions +}), +``` + +#### SkipAudits + +Optional parameter. The `skipAudits` option allows you to exclude specific documentation types from measurement. All other types will be included in the results. + +```js +docCoveragePlugin({ + sourceGlob: ['src/**/*.ts'], + skipAudits: [ + 'variables-coverage', + 'interfaces-coverage' + ] // Measure all documentation types except variables and interfaces +}), +``` + +> ⚠️ **Warning:** You cannot use both `onlyAudits` and `skipAudits` in the same configuration. Choose the one that better suits your needs. + +### Audits and group + +This plugin provides a group for convenient declaration in your config. When defined this way, all measured documentation type audits have the same weight. + +```ts + // ... + categories: [ + { + slug: 'documentation', + title: 'Documentation', + refs: [ + { + type: 'group', + plugin: 'doc-coverage', + slug: 'doc-coverage', + weight: 1, + }, + // ... + ], + }, + // ... + ], +``` + +Each documentation type still has its own audit. So when you want to include a subset of documentation types or assign different weights to them, you can do so in the following way: + +```ts + // ... + categories: [ + { + slug: 'documentation', + title: 'Documentation', + refs: [ + { + type: 'audit', + plugin: 'doc-coverage', + slug: 'class-doc-coverage', + weight: 2, + }, + { + type: 'audit', + plugin: 'doc-coverage', + slug: 'function-doc-coverage', + weight: 1, + }, + // ... + ], + }, + // ... + ], +``` + +### Audit output + +The plugin outputs a single audit that measures the overall documentation coverage percentage of your codebase. + +For instance, this is an example of the plugin output: + +```json +{ + "packageName": "@code-pushup/doc-coverage-plugin", + "version": "0.57.0", + "title": "Documentation coverage", + "slug": "doc-coverage", + "icon": "folder-src", + "duration": 920, + "date": "2024-12-17T16:45:28.581Z", + "audits": [ + { + "slug": "percentage-coverage", + "displayValue": "16 %", + "value": 16, + "score": 0.16, + "details": { + "issues": [] + }, + "title": "Percentage of codebase with documentation", + "description": "Measures how many % of the codebase have documentation." + } + ], + "description": "Official Code PushUp documentation coverage plugin.", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/doc-coverage-plugin/", + "groups": [ + { + "slug": "doc-coverage", + "refs": [ + { + "slug": "percentage-coverage", + "weight": 1 + } + ], + "title": "Documentation coverage metrics", + "description": "Group containing all defined documentation coverage types as audits." + } + ] +} +``` diff --git a/packages/plugin-doc-coverage/eslint.config.js b/packages/plugin-doc-coverage/eslint.config.js new file mode 100644 index 000000000..40165321a --- /dev/null +++ b/packages/plugin-doc-coverage/eslint.config.js @@ -0,0 +1,21 @@ +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, + }, + }, + }, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': 'error', + }, + }, +); diff --git a/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.css b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.css new file mode 100644 index 000000000..f3958f2b4 --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.css @@ -0,0 +1,4 @@ +h1 { + color: #336699; + text-align: center; +} diff --git a/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.html b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.html new file mode 100644 index 000000000..b6515528b --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.html @@ -0,0 +1 @@ +

{{ title }}

diff --git a/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.spec.ts b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.spec.ts new file mode 100644 index 000000000..c89f47dd8 --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.spec.ts @@ -0,0 +1,3 @@ +function notRealisticFunction() { + return 'notRealisticFunction'; +} diff --git a/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.ts b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.ts new file mode 100644 index 000000000..2fc2b165f --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/fixtures/angular/app.component.ts @@ -0,0 +1,18 @@ +/** + * Basic Angular component that displays a welcome message + */ +export class AppComponent { + protected readonly title = 'My Angular App'; + + /** + * Dummy method that returns a welcome message + * @returns {string} - The welcome message + */ + getWelcomeMessage() { + return 'Welcome to My Angular App!'; + } + + sendEvent() { + return 'Event sent'; + } +} diff --git a/packages/plugin-doc-coverage/mocks/fixtures/angular/map-event.function.ts b/packages/plugin-doc-coverage/mocks/fixtures/angular/map-event.function.ts new file mode 100644 index 000000000..55f343e7c --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/fixtures/angular/map-event.function.ts @@ -0,0 +1,10 @@ +export const someVariable = 'Hello World 1'; + +export function mapEventToCustomEvent(event: string) { + return event; +} + +/** Commented */ +export function mapCustomEventToEvent(event: string) { + return event; +} diff --git a/packages/plugin-doc-coverage/mocks/fixtures/react/component.js b/packages/plugin-doc-coverage/mocks/fixtures/react/component.js new file mode 100644 index 000000000..dfa1336e3 --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/fixtures/react/component.js @@ -0,0 +1,10 @@ +function MyComponent() { + return ( +
+

Hello World

+

This is a basic React component

+
+ ); +} + +export default MyComponent; diff --git a/packages/plugin-doc-coverage/mocks/source-files-mock.generator.ts b/packages/plugin-doc-coverage/mocks/source-files-mock.generator.ts new file mode 100644 index 000000000..5d700db3c --- /dev/null +++ b/packages/plugin-doc-coverage/mocks/source-files-mock.generator.ts @@ -0,0 +1,82 @@ +import { + ClassDeclaration, + EnumDeclaration, + FunctionDeclaration, + InterfaceDeclaration, + SourceFile, + SyntaxKind, + TypeAliasDeclaration, + VariableStatement, +} from 'ts-morph'; +import type { CoverageType } from '../src/lib/runner/models.js'; + +export function sourceFileMock( + file: string, + nodes: Partial>>, +): SourceFile { + const createNodeGetter = ( + coverageType: CoverageType, + nodeData?: Record, + ) => { + if (!nodeData) return []; + return Object.entries(nodeData).map(([line, isCommented]) => + nodeMock({ coverageType, line: Number(line), file, isCommented }), + ) as unknown as T[]; + }; + + return { + getFilePath: () => file as any, + getClasses: () => + createNodeGetter('classes', nodes.classes), + getFunctions: () => + createNodeGetter('functions', nodes.functions), + getEnums: () => createNodeGetter('enums', nodes.enums), + getTypeAliases: () => + createNodeGetter('types', nodes.types), + getInterfaces: () => + createNodeGetter('interfaces', nodes.interfaces), + getVariableStatements: () => + createNodeGetter('variables', nodes.variables), + } as SourceFile; +} + +export function nodeMock(options: { + coverageType: CoverageType; + line: number; + file: string; + isCommented: boolean; +}) { + return { + getKind: () => getKindFromCoverageType(options.coverageType), + getJsDocs: () => (options.isCommented ? ['Comment'] : []), + getName: () => 'test', + getStartLineNumber: () => options.line, + getDeclarations: () => [], + // Only for classes + getMethods: () => [], + getProperties: () => [], + }; +} + +function getKindFromCoverageType(coverageType: CoverageType): SyntaxKind { + switch (coverageType) { + case 'classes': + return SyntaxKind.ClassDeclaration; + case 'methods': + return SyntaxKind.MethodDeclaration; + case 'functions': + return SyntaxKind.FunctionDeclaration; + case 'interfaces': + return SyntaxKind.InterfaceDeclaration; + case 'enums': + return SyntaxKind.EnumDeclaration; + case 'variables': + return SyntaxKind.VariableDeclaration; + case 'properties': + return SyntaxKind.PropertyDeclaration; + case 'types': + return SyntaxKind.TypeAliasDeclaration; + default: + throw new Error(`Unsupported syntax kind: ${coverageType}`); + } +} diff --git a/packages/plugin-doc-coverage/package.json b/packages/plugin-doc-coverage/package.json new file mode 100644 index 000000000..20f0b4f52 --- /dev/null +++ b/packages/plugin-doc-coverage/package.json @@ -0,0 +1,42 @@ +{ + "name": "@code-pushup/doc-coverage-plugin", + "version": "0.57.0", + "description": "Code PushUp plugin for tracking documentation coverage 📚", + "license": "MIT", + "homepage": "https://github.com/code-pushup/cli/tree/main/packages/plugin-doc-coverage#readme", + "bugs": { + "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3A\"🧩%20doc-coverage-plugin\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/code-pushup/cli.git", + "directory": "packages/plugin-doc-coverage" + }, + "keywords": [ + "documentation coverage", + "documentation quality", + "docs completeness", + "automated documentation checks", + "coverage audit", + "documentation conformance", + "docs KPI tracking", + "documentation feedback", + "actionable documentation insights", + "documentation regression guard", + "documentation score monitoring", + "developer documentation tools", + "plugin for documentation coverage", + "CLI documentation coverage", + "Code PushUp documentation", + "documentation audit" + ], + "publishConfig": { + "access": "public" + }, + "type": "module", + "dependencies": { + "@code-pushup/models": "0.57.0", + "zod": "^3.22.4", + "ts-morph": "^24.0.0" + } +} diff --git a/packages/plugin-doc-coverage/project.json b/packages/plugin-doc-coverage/project.json new file mode 100644 index 000000000..ea971b133 --- /dev/null +++ b/packages/plugin-doc-coverage/project.json @@ -0,0 +1,42 @@ +{ + "name": "plugin-doc-coverage", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugin-doc-coverage/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/plugin-doc-coverage", + "main": "packages/plugin-doc-coverage/src/index.ts", + "tsConfig": "packages/plugin-doc-coverage/tsconfig.lib.json", + "additionalEntryPoints": ["packages/plugin-doc-coverage/src/bin.ts"], + "assets": ["packages/plugin-doc-coverage/*.md"] + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "packages/plugin-doc-coverage/**/*.ts", + "packages/plugin-doc-coverage/package.json" + ] + } + }, + "unit-test": { + "executor": "@nx/vite:test", + "options": { + "configFile": "packages/plugin-doc-coverage/vite.config.unit.ts" + } + }, + "integration-test": { + "executor": "@nx/vite:test", + "options": { + "configFile": "packages/plugin-doc-coverage/vite.config.integration.ts" + } + } + }, + "tags": ["scope:plugin", "type:feature", "publishable"] +} diff --git a/packages/plugin-doc-coverage/src/index.ts b/packages/plugin-doc-coverage/src/index.ts new file mode 100644 index 000000000..d26f57375 --- /dev/null +++ b/packages/plugin-doc-coverage/src/index.ts @@ -0,0 +1,4 @@ +import { docCoveragePlugin } from './lib/doc-coverage-plugin.js'; + +export default docCoveragePlugin; +export type { DocCoveragePluginConfig } from './lib/config.js'; diff --git a/packages/plugin-doc-coverage/src/lib/config.ts b/packages/plugin-doc-coverage/src/lib/config.ts new file mode 100644 index 000000000..1204ca7f7 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/config.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const docCoveragePluginConfigSchema = z + .object({ + skipAudits: z + .array(z.string()) + .optional() + .describe( + 'List of audit slugs to exclude from evaluation. When specified, all audits except these will be evaluated.', + ), + onlyAudits: z + .array(z.string()) + .optional() + .describe( + 'List of audit slugs to evaluate. When specified, only these audits will be evaluated.', + ), + sourceGlob: z + .array(z.string()) + .default(['src/**/*.{ts,tsx}', '!**/*.spec.ts', '!**/*.test.ts']) + .describe('Glob pattern to match source files to evaluate.'), + }) + .refine(data => !(data.skipAudits && data.onlyAudits), { + message: "You can't define 'skipAudits' and 'onlyAudits' simultaneously", + path: ['skipAudits', 'onlyAudits'], + }); + +export type DocCoveragePluginConfig = z.infer< + typeof docCoveragePluginConfigSchema +>; diff --git a/packages/plugin-doc-coverage/src/lib/config.unit.test.ts b/packages/plugin-doc-coverage/src/lib/config.unit.test.ts new file mode 100644 index 000000000..4d4b4f1ea --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/config.unit.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { + type DocCoveragePluginConfig, + docCoveragePluginConfigSchema, +} from './config.js'; + +describe('DocCoveragePlugin Configuration', () => { + describe('docCoveragePluginConfigSchema', () => { + it('accepts a valid configuration', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + sourceGlob: ['src/**/*.ts'], + onlyAudits: ['functions-coverage'], + } satisfies DocCoveragePluginConfig), + ).not.toThrow(); + }); + + it('throws when skipAudits and onlyAudits are defined', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + skipAudits: ['functions-coverage'], + onlyAudits: ['classes-coverage'], + }), + ).toThrow( + "You can't define 'skipAudits' and 'onlyAudits' simultaneously", + ); + }); + }); + + describe('sourceGlob', () => { + it('accepts a valid source glob pattern', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + sourceGlob: ['src/**/*.{ts,tsx}', '!**/*.spec.ts', '!**/*.test.ts'], + } satisfies DocCoveragePluginConfig), + ).not.toThrow(); + }); + + it('uses default value for missing sourceGlob', () => { + const result = docCoveragePluginConfigSchema.parse({}); + expect(result.sourceGlob).toEqual([ + 'src/**/*.{ts,tsx}', + '!**/*.spec.ts', + '!**/*.test.ts', + ]); + }); + + it('throws for invalid sourceGlob type', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + sourceGlob: 123, + }), + ).toThrow('Expected array'); + }); + }); + + describe('onlyAudits', () => { + it('accepts a valid `onlyAudits` array', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + onlyAudits: ['functions-coverage', 'classes-coverage'], + sourceGlob: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('accepts empty array for onlyAudits', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + onlyAudits: [], + sourceGlob: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('allows onlyAudits to be undefined', () => { + const result = docCoveragePluginConfigSchema.parse({}); + expect(result.onlyAudits).toBeUndefined(); + }); + + it('throws for invalid onlyAudits type', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + onlyAudits: 'functions-coverage', + }), + ).toThrow('Expected array'); + }); + + it('throws for array with non-string elements', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + onlyAudits: [123, true], + }), + ).toThrow('Expected string, received number'); + }); + }); + + describe('skipAudits', () => { + it('accepts valid audit slugs array', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + skipAudits: ['functions-coverage', 'classes-coverage'], + sourceGlob: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('accepts empty array for skipAudits', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + skipAudits: [], + sourceGlob: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('allows skipAudits to be undefined', () => { + const result = docCoveragePluginConfigSchema.parse({}); + expect(result.skipAudits).toBeUndefined(); + }); + + it('throws for invalid skipAudits type', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + skipAudits: 'functions-coverage', + }), + ).toThrow('Expected array'); + }); + + it('throws for array with non-string elements', () => { + expect(() => + docCoveragePluginConfigSchema.parse({ + skipAudits: [123, true], + }), + ).toThrow('Expected string'); + }); + }); +}); diff --git a/packages/plugin-doc-coverage/src/lib/constants.ts b/packages/plugin-doc-coverage/src/lib/constants.ts new file mode 100644 index 000000000..cbd09b671 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/constants.ts @@ -0,0 +1,65 @@ +import type { Audit, Group } from '@code-pushup/models'; +import type { AuditSlug } from './models.js'; + +export const PLUGIN_SLUG = 'doc-coverage'; + +export const AUDITS_MAP: Record = { + 'classes-coverage': { + slug: 'classes-coverage', + title: 'Classes coverage', + description: 'Documentation coverage of classes', + }, + 'methods-coverage': { + slug: 'methods-coverage', + title: 'Methods coverage', + description: 'Documentation coverage of methods', + }, + 'functions-coverage': { + slug: 'functions-coverage', + title: 'Functions coverage', + description: 'Documentation coverage of functions', + }, + 'interfaces-coverage': { + slug: 'interfaces-coverage', + title: 'Interfaces coverage', + description: 'Documentation coverage of interfaces', + }, + 'variables-coverage': { + slug: 'variables-coverage', + title: 'Variables coverage', + description: 'Documentation coverage of variables', + }, + 'properties-coverage': { + slug: 'properties-coverage', + title: 'Properties coverage', + description: 'Documentation coverage of properties', + }, + 'types-coverage': { + slug: 'types-coverage', + title: 'Types coverage', + description: 'Documentation coverage of types', + }, + 'enums-coverage': { + slug: 'enums-coverage', + title: 'Enums coverage', + description: 'Documentation coverage of enums', + }, +} as const; + +export const groups: Group[] = [ + { + slug: 'documentation-coverage', + title: 'Documentation coverage', + description: 'Documentation coverage', + refs: Object.keys(AUDITS_MAP).map(slug => ({ + slug, + weight: [ + 'classes-coverage', + 'functions-coverage', + 'methods-coverage', + ].includes(slug) + ? 2 + : 1, + })), + }, +]; diff --git a/packages/plugin-doc-coverage/src/lib/doc-coverage-plugin.ts b/packages/plugin-doc-coverage/src/lib/doc-coverage-plugin.ts new file mode 100644 index 000000000..dc594a303 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/doc-coverage-plugin.ts @@ -0,0 +1,54 @@ +import type { PluginConfig } from '@code-pushup/models'; +import { + type DocCoveragePluginConfig, + docCoveragePluginConfigSchema, +} from './config.js'; +import { PLUGIN_SLUG, groups } from './constants.js'; +import { createRunnerFunction } from './runner/runner.js'; +import { + filterAuditsByPluginConfig, + filterGroupsByOnlyAudits, +} from './utils.js'; + +export const PLUGIN_TITLE = 'Documentation coverage'; + +export const PLUGIN_DESCRIPTION = + 'Official Code PushUp documentation coverage plugin.'; + +export const PLUGIN_DOCS_URL = + 'https://www.npmjs.com/package/@code-pushup/doc-coverage-plugin/'; + +/** + * Instantiates Code PushUp documentation coverage plugin for core config. + * + * @example + * import docCoveragePlugin from '@code-pushup/doc-coverage-plugin' + * + * export default { + * // ... core config ... + * plugins: [ + * // ... other plugins ... + * docCoveragePlugin({ + * sourceGlob: ['src/**/*.{ts,tsx}'] + * }) + * ] + * } + * + * @returns Plugin configuration. + */ +export function docCoveragePlugin( + config: DocCoveragePluginConfig, +): PluginConfig { + const docCoverageConfig = docCoveragePluginConfigSchema.parse(config); + + return { + slug: PLUGIN_SLUG, + title: PLUGIN_TITLE, + icon: 'folder-src', + description: PLUGIN_DESCRIPTION, + docsUrl: PLUGIN_DOCS_URL, + groups: filterGroupsByOnlyAudits(groups, docCoverageConfig), + audits: filterAuditsByPluginConfig(docCoverageConfig), + runner: createRunnerFunction(docCoverageConfig), + }; +} diff --git a/packages/plugin-doc-coverage/src/lib/doc-coverage-plugin.unit.test.ts b/packages/plugin-doc-coverage/src/lib/doc-coverage-plugin.unit.test.ts new file mode 100644 index 000000000..3fda06e71 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/doc-coverage-plugin.unit.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PLUGIN_SLUG, groups } from './constants.js'; +import { + PLUGIN_DESCRIPTION, + PLUGIN_DOCS_URL, + PLUGIN_TITLE, + docCoveragePlugin, +} from './doc-coverage-plugin.js'; +import { createRunnerFunction } from './runner/runner.js'; +import { + filterAuditsByPluginConfig, + filterGroupsByOnlyAudits, +} from './utils.js'; + +vi.mock('./utils.js', () => ({ + filterAuditsByPluginConfig: vi.fn().mockReturnValue(['mockAudit']), + filterGroupsByOnlyAudits: vi.fn().mockReturnValue(['mockGroup']), +})); + +vi.mock('./runner/runner.js', () => ({ + createRunnerFunction: vi.fn().mockReturnValue(() => Promise.resolve([])), +})); + +describe('docCoveragePlugin', () => { + it('should create a valid plugin config', () => { + expect( + docCoveragePlugin({ + sourceGlob: ['src/**/*.ts', '!**/*.spec.ts', '!**/*.test.ts'], + }), + ).toStrictEqual( + expect.objectContaining({ + slug: PLUGIN_SLUG, + title: PLUGIN_TITLE, + icon: 'folder-src', + description: PLUGIN_DESCRIPTION, + docsUrl: PLUGIN_DOCS_URL, + groups: expect.any(Array), + audits: expect.any(Array), + runner: expect.any(Function), + }), + ); + }); + + it('should throw for invalid plugin options', () => { + expect(() => + docCoveragePlugin({ + // @ts-expect-error testing invalid config + sourceGlob: 123, + }), + ).toThrow('Expected array, received number'); + }); + + it('should filter groups', () => { + const config = { sourceGlob: ['src/**/*.ts'] }; + docCoveragePlugin(config); + + expect(filterGroupsByOnlyAudits).toHaveBeenCalledWith(groups, config); + }); + + it('should filter audits', async () => { + const config = { sourceGlob: ['src/**/*.ts'] }; + docCoveragePlugin(config); + + expect(filterAuditsByPluginConfig).toHaveBeenCalledWith(config); + }); + + it('should forward options to runner function', async () => { + const config = { sourceGlob: ['src/**/*.ts'] }; + docCoveragePlugin(config); + + expect(createRunnerFunction).toHaveBeenCalledWith(config); + }); +}); diff --git a/packages/plugin-doc-coverage/src/lib/models.ts b/packages/plugin-doc-coverage/src/lib/models.ts new file mode 100644 index 000000000..a407d2a74 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/models.ts @@ -0,0 +1,3 @@ +import type { CoverageType } from './runner/models.js'; + +export type AuditSlug = `${CoverageType}-coverage`; diff --git a/packages/plugin-doc-coverage/src/lib/runner/__snapshots__/doc-processer.unit.test.ts.snap b/packages/plugin-doc-coverage/src/lib/runner/__snapshots__/doc-processer.unit.test.ts.snap new file mode 100644 index 000000000..3a9c6a965 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/__snapshots__/doc-processer.unit.test.ts.snap @@ -0,0 +1,92 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`getDocumentationReport > should produce a full report 1`] = ` +{ + "classes": { + "coverage": 33.33, + "issues": [ + { + "file": "test.ts", + "line": 4, + "name": "test", + "type": "classes", + }, + { + "file": "test.ts", + "line": 5, + "name": "test", + "type": "classes", + }, + ], + "nodesCount": 3, + }, + "enums": { + "coverage": 33.33, + "issues": [ + { + "file": "test.ts", + "line": 8, + "name": "test", + "type": "enums", + }, + { + "file": "test.ts", + "line": 9, + "name": "test", + "type": "enums", + }, + ], + "nodesCount": 3, + }, + "functions": { + "coverage": 100, + "issues": [], + "nodesCount": 3, + }, + "interfaces": { + "coverage": 66.67, + "issues": [ + { + "file": "test.ts", + "line": 15, + "name": "test", + "type": "interfaces", + }, + ], + "nodesCount": 3, + }, + "methods": { + "coverage": 100, + "issues": [], + "nodesCount": 0, + }, + "properties": { + "coverage": 100, + "issues": [], + "nodesCount": 0, + }, + "types": { + "coverage": 50, + "issues": [ + { + "file": "test.ts", + "line": 10, + "name": "test", + "type": "types", + }, + { + "file": "test.ts", + "line": 11, + "name": "test", + "type": "types", + }, + ], + "nodesCount": 4, + }, + "variables": { + "coverage": 100, + "issues": [], + "nodesCount": 0, + }, +} +`; diff --git a/packages/plugin-doc-coverage/src/lib/runner/doc-processer.integration.test.ts b/packages/plugin-doc-coverage/src/lib/runner/doc-processer.integration.test.ts new file mode 100644 index 000000000..2f8cb4864 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/doc-processer.integration.test.ts @@ -0,0 +1,65 @@ +import { processDocCoverage } from './doc-processer.js'; + +describe('processDocCoverage', () => { + it('should count total nodes from TypeScript files correctly', () => { + const sourcePath = + 'packages/plugin-doc-coverage/mocks/fixtures/angular/**/*.ts'; + const expectedNodeCount = 8; + + const results = processDocCoverage({ sourceGlob: [sourcePath] }); + + const totalNodeCount = Object.values(results).reduce( + (acc, node) => acc + node.nodesCount, + 0, + ); + + expect(totalNodeCount).toBe(expectedNodeCount); + }); + + it('should count total nodes from Javascript files correctly', () => { + const sourcePath = + 'packages/plugin-doc-coverage/mocks/fixtures/react/**/*.js'; + const expectedNodeCount = 1; + + const results = processDocCoverage({ sourceGlob: [sourcePath] }); + + const totalNodeCount = Object.values(results).reduce( + (acc, node) => acc + node.nodesCount, + 0, + ); + + expect(totalNodeCount).toBe(expectedNodeCount); + }); + + it('should count total nodes from Javascript and TypeScript files correctly', () => { + const sourcePath = + 'packages/plugin-doc-coverage/mocks/fixtures/**/*.{js,ts}'; + const expectedNodeCount = 9; + + const results = processDocCoverage({ sourceGlob: [sourcePath] }); + + const totalNodeCount = Object.values(results).reduce( + (acc, node) => acc + node.nodesCount, + 0, + ); + + expect(totalNodeCount).toBe(expectedNodeCount); + }); + + it('respect `sourceGlob` and only include matching files', () => { + const sourcePath = + 'packages/plugin-doc-coverage/mocks/fixtures/angular/**/*.ts'; + const expectedNodeCount = 7; + + const results = processDocCoverage({ + sourceGlob: [sourcePath, '!**/*.spec.ts', '!**/*.test.ts'], + }); + + const totalNodeCount = Object.values(results).reduce( + (acc, node) => acc + node.nodesCount, + 0, + ); + + expect(totalNodeCount).toBe(expectedNodeCount); + }); +}); diff --git a/packages/plugin-doc-coverage/src/lib/runner/doc-processer.ts b/packages/plugin-doc-coverage/src/lib/runner/doc-processer.ts new file mode 100644 index 000000000..82be50608 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/doc-processer.ts @@ -0,0 +1,157 @@ +import { + ClassDeclaration, + Project, + SourceFile, + VariableStatement, +} from 'ts-morph'; +import type { DocCoveragePluginConfig } from '../config.js'; +import type { + CoverageType, + DocumentationCoverageReport, + DocumentationReport, +} from './models.js'; +import { + calculateCoverage, + createEmptyCoverageData, + getCoverageTypeFromKind, +} from './utils.js'; + +/** + * Gets the variables information from the variable statements + * @param variableStatements - The variable statements to process + * @returns {Node[]} The variables information with the right methods to get the information + */ +export function getVariablesInformation( + variableStatements: VariableStatement[], +) { + return variableStatements.flatMap(variable => { + // Get parent-level information + const parentInfo = { + getKind: () => variable.getKind(), + getJsDocs: () => variable.getJsDocs(), + getStartLineNumber: () => variable.getStartLineNumber(), + }; + + // Map each declaration to combine parent info with declaration-specific info + return variable.getDeclarations().map(declaration => ({ + ...parentInfo, + getName: () => declaration.getName(), + })); + }); +} + +/** + * Processes documentation coverage for TypeScript files in the specified path + * @param toInclude - The file path pattern to include for documentation analysis + * @returns {DocumentationCoverageReport} Object containing coverage statistics and undocumented items + */ +export function processDocCoverage( + config: DocCoveragePluginConfig, +): DocumentationCoverageReport { + const project = new Project(); + project.addSourceFilesAtPaths(config.sourceGlob); + return getDocumentationReport(project.getSourceFiles()); +} + +export function getAllNodesFromASourceFile(sourceFile: SourceFile) { + const classes = sourceFile.getClasses(); + return [ + ...sourceFile.getFunctions(), + ...classes, + ...getClassNodes(classes), + ...sourceFile.getTypeAliases(), + ...sourceFile.getEnums(), + ...sourceFile.getInterfaces(), + ...getVariablesInformation(sourceFile.getVariableStatements()), + ]; +} + +/** + * Gets the documentation coverage report from the source files + * @param sourceFiles - The source files to process + * @returns {DocumentationCoverageReport} The documentation coverage report + */ +export function getDocumentationReport( + sourceFiles: SourceFile[], +): DocumentationCoverageReport { + const unprocessedCoverageReport = sourceFiles.reduce( + (coverageReportOfAllFiles, sourceFile) => { + const filePath = sourceFile.getFilePath(); + const allNodesFromFile = getAllNodesFromASourceFile(sourceFile); + + const coverageReportOfCurrentFile = allNodesFromFile.reduce( + (acc, node) => { + const nodeType = getCoverageTypeFromKind(node.getKind()); + const currentTypeReport = acc[nodeType]; + const updatedIssues = + node.getJsDocs().length === 0 + ? [ + ...currentTypeReport.issues, + { + file: filePath, + type: nodeType, + name: node.getName() || '', + line: node.getStartLineNumber(), + }, + ] + : currentTypeReport.issues; + + return { + ...acc, + [nodeType]: { + nodesCount: currentTypeReport.nodesCount + 1, + issues: updatedIssues, + }, + }; + }, + createEmptyCoverageData(), + ); + + return mergeDocumentationReports( + coverageReportOfAllFiles, + coverageReportOfCurrentFile, + ); + }, + createEmptyCoverageData(), + ); + + return calculateCoverage(unprocessedCoverageReport); +} + +/** + * Merges two documentation results + * @param accumulatedReport - The first empty documentation result + * @param currentFileReport - The second documentation result + * @returns {DocumentationReport} The merged documentation result + */ +export function mergeDocumentationReports( + accumulatedReport: DocumentationReport, + currentFileReport: Partial, +): DocumentationReport { + return Object.fromEntries( + Object.entries(accumulatedReport).map(([key, value]) => { + const node = value as DocumentationCoverageReport[CoverageType]; + const type = key as CoverageType; + return [ + type, + { + nodesCount: + node.nodesCount + (currentFileReport[type]?.nodesCount ?? 0), + issues: [...node.issues, ...(currentFileReport[type]?.issues ?? [])], + }, + ]; + }), + ) as DocumentationReport; +} + +/** + * Gets the nodes from a class + * @param classNodes - The class nodes to process + * @returns {Node[]} The nodes from the class + */ +export function getClassNodes(classNodes: ClassDeclaration[]) { + return classNodes.flatMap(classNode => [ + ...classNode.getMethods(), + ...classNode.getProperties(), + ]); +} diff --git a/packages/plugin-doc-coverage/src/lib/runner/doc-processer.unit.test.ts b/packages/plugin-doc-coverage/src/lib/runner/doc-processer.unit.test.ts new file mode 100644 index 000000000..8cf66f43f --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/doc-processer.unit.test.ts @@ -0,0 +1,313 @@ +import type { ClassDeclaration, VariableStatement } from 'ts-morph'; +import { + nodeMock, + sourceFileMock, +} from '../../../mocks/source-files-mock.generator'; +import { + getAllNodesFromASourceFile, + getClassNodes, + getDocumentationReport, + getVariablesInformation, + mergeDocumentationReports, +} from './doc-processer.js'; +import type { DocumentationReport } from './models.js'; + +describe('getDocumentationReport', () => { + it('should produce a full report', () => { + const results = getDocumentationReport([ + sourceFileMock('test.ts', { + functions: { 1: true, 2: true, 3: true }, + classes: { 4: false, 5: false, 6: true }, + enums: { 7: true, 8: false, 9: false }, + types: { 10: false, 11: false, 12: true, 40: true }, + interfaces: { 13: true, 14: true, 15: false }, + properties: { 16: false, 17: false, 18: false }, + variables: { 22: true, 23: true, 24: true }, + }), + ]); + expect(results).toMatchSnapshot(); + }); + + it('should accept array of source files', () => { + const results = getDocumentationReport([ + sourceFileMock('test.ts', { functions: { 1: true, 2: true, 3: false } }), + ]); + expect(results).toBeDefined(); + }); + + it('should count nodes correctly', () => { + const results = getDocumentationReport([ + sourceFileMock('test.ts', { functions: { 1: true, 2: true, 3: false } }), + ]); + + expect(results.functions.nodesCount).toBe(3); + }); + + it('should collect uncommented nodes issues', () => { + const results = getDocumentationReport([ + sourceFileMock('test.ts', { functions: { 1: true, 2: false, 3: false } }), + ]); + + expect(results.functions.issues).toHaveLength(2); + }); + + it('should collect valid issues', () => { + const results = getDocumentationReport([ + sourceFileMock('test.ts', { functions: { 1: false } }), + ]); + + expect(results.functions.issues).toStrictEqual([ + { + line: 1, + file: 'test.ts', + type: 'functions', + name: 'test', + }, + ]); + }); + + it('should calculate coverage correctly', () => { + const results = getDocumentationReport([ + sourceFileMock('test.ts', { functions: { 1: true, 2: false } }), + ]); + + expect(results.functions.coverage).toBe(50); + }); +}); + +describe('mergeDocumentationReports', () => { + const emptyResult: DocumentationReport = { + enums: { nodesCount: 0, issues: [] }, + interfaces: { nodesCount: 0, issues: [] }, + types: { nodesCount: 0, issues: [] }, + functions: { nodesCount: 0, issues: [] }, + variables: { nodesCount: 0, issues: [] }, + classes: { nodesCount: 0, issues: [] }, + methods: { nodesCount: 0, issues: [] }, + properties: { nodesCount: 0, issues: [] }, + }; + + it.each([ + 'enums', + 'interfaces', + 'types', + 'functions', + 'variables', + 'classes', + 'methods', + 'properties', + ])('should merge results on top-level property: %s', type => { + const secondResult = { + [type]: { + nodesCount: 1, + issues: [{ file: 'test2.ts', line: 1, name: 'test2', type }], + }, + }; + + const results = mergeDocumentationReports( + emptyResult, + secondResult as Partial, + ); + expect(results).toStrictEqual( + expect.objectContaining({ + [type]: { + nodesCount: 1, + issues: [{ file: 'test2.ts', line: 1, name: 'test2', type }], + }, + }), + ); + }); + + it('should merge empty results', () => { + const results = mergeDocumentationReports(emptyResult, emptyResult); + expect(results).toStrictEqual(emptyResult); + }); + + it('should merge second level property nodesCount', () => { + const results = mergeDocumentationReports( + { + ...emptyResult, + enums: { nodesCount: 1, issues: [] }, + }, + { + enums: { nodesCount: 1, issues: [] }, + }, + ); + expect(results.enums.nodesCount).toBe(2); + }); + + it('should merge second level property issues', () => { + const results = mergeDocumentationReports( + { + ...emptyResult, + enums: { + nodesCount: 0, + issues: [ + { + file: 'file.enum-first.ts', + line: 6, + name: 'file.enum-first', + type: 'enums', + }, + ], + }, + }, + { + enums: { + nodesCount: 0, + issues: [ + { + file: 'file.enum-second.ts', + line: 5, + name: 'file.enum-second', + type: 'enums', + }, + ], + }, + }, + ); + expect(results.enums.issues).toStrictEqual([ + { + file: 'file.enum-first.ts', + line: 6, + name: 'file.enum-first', + type: 'enums', + }, + { + file: 'file.enum-second.ts', + line: 5, + name: 'file.enum-second', + type: 'enums', + }, + ]); + }); +}); + +describe('getClassNodes', () => { + it('should return all nodes from a class', () => { + const nodeMock1 = nodeMock({ + coverageType: 'classes', + line: 1, + file: 'test.ts', + isCommented: false, + }); + + const classNodeSpy = vi.spyOn(nodeMock1, 'getMethods'); + const propertyNodeSpy = vi.spyOn(nodeMock1, 'getProperties'); + + getClassNodes([nodeMock1] as unknown as ClassDeclaration[]); + + expect(classNodeSpy).toHaveBeenCalledTimes(1); + expect(propertyNodeSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('getVariablesInformation', () => { + it('should process variable statements correctly', () => { + const mockDeclaration = { + getName: () => 'testVariable', + }; + + const mockVariableStatement = { + getKind: () => 'const', + getJsDocs: () => ['some docs'], + getStartLineNumber: () => 42, + getDeclarations: () => [mockDeclaration], + }; + + const result = getVariablesInformation([ + mockVariableStatement as unknown as VariableStatement, + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + getKind: expect.any(Function), + getJsDocs: expect.any(Function), + getStartLineNumber: expect.any(Function), + getName: expect.any(Function), + }); + // It must be defined + expect(result[0]!.getName()).toBe('testVariable'); + expect(result[0]!.getKind()).toBe('const'); + expect(result[0]!.getJsDocs()).toEqual(['some docs']); + expect(result[0]!.getStartLineNumber()).toBe(42); + }); + + it('should handle multiple declarations in a single variable statement', () => { + const mockDeclarations = [ + { getName: () => 'var1' }, + { getName: () => 'var2' }, + ]; + + const mockVariableStatement = { + getKind: () => 'let', + getJsDocs: () => [], + getStartLineNumber: () => 10, + getDeclarations: () => mockDeclarations, + }; + + const result = getVariablesInformation([ + mockVariableStatement as unknown as VariableStatement, + ]); + + expect(result).toHaveLength(2); + // They must be defined + expect(result[0]!.getName()).toBe('var1'); + expect(result[1]!.getName()).toBe('var2'); + expect(result[0]!.getKind()).toBe('let'); + expect(result[1]!.getKind()).toBe('let'); + }); + + it('should handle empty variable statements array', () => { + const result = getVariablesInformation([]); + expect(result).toHaveLength(0); + }); + + it('should handle variable statements without declarations', () => { + const mockVariableStatement = { + getKind: () => 'const', + getJsDocs: () => [], + getStartLineNumber: () => 1, + getDeclarations: () => [], + }; + + const result = getVariablesInformation([ + mockVariableStatement as unknown as VariableStatement, + ]); + expect(result).toHaveLength(0); + }); +}); + +describe('getAllNodesFromASourceFile', () => { + it('should combine all node types from a source file', () => { + const mockSourceFile = sourceFileMock('test.ts', { + functions: { 1: true }, + classes: { 2: false }, + types: { 3: true }, + enums: { 4: false }, + interfaces: { 5: true }, + }); + + const result = getAllNodesFromASourceFile(mockSourceFile); + + expect(result).toHaveLength(5); + }); + + it('should handle empty source file', () => { + const mockSourceFile = sourceFileMock('empty.ts', {}); + + const result = getAllNodesFromASourceFile(mockSourceFile); + + expect(result).toHaveLength(0); + }); + + it('should handle source file with only functions', () => { + const mockSourceFile = sourceFileMock('functions.ts', { + functions: { 1: true, 2: false, 3: true }, + }); + + const result = getAllNodesFromASourceFile(mockSourceFile); + + expect(result).toHaveLength(3); + }); +}); diff --git a/packages/plugin-doc-coverage/src/lib/runner/models.ts b/packages/plugin-doc-coverage/src/lib/runner/models.ts new file mode 100644 index 000000000..d95b7b47b --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/models.ts @@ -0,0 +1,43 @@ +import type { SyntaxKind } from 'ts-morph'; + +/** Maps the SyntaxKind from the library ts-morph to the coverage type. */ +type SyntaxKindToStringLiteral = { + [SyntaxKind.ClassDeclaration]: 'classes'; + [SyntaxKind.MethodDeclaration]: 'methods'; + [SyntaxKind.FunctionDeclaration]: 'functions'; + [SyntaxKind.InterfaceDeclaration]: 'interfaces'; + [SyntaxKind.EnumDeclaration]: 'enums'; + [SyntaxKind.VariableDeclaration]: 'variables'; + [SyntaxKind.PropertyDeclaration]: 'properties'; + [SyntaxKind.TypeAliasDeclaration]: 'types'; +}; + +/**The coverage type is the same as the SyntaxKind from the library ts-morph but as a string. */ +export type CoverageType = + SyntaxKindToStringLiteral[keyof SyntaxKindToStringLiteral]; + +/** The undocumented node is the node that is not documented and has the information for the report. */ +export type UndocumentedNode = { + file: string; + type: CoverageType; + name: string; + line: number; + class?: string; +}; + +/** The documentation data has the issues and the total nodes count from a specific CoverageType. */ +export type DocumentationData = { + issues: UndocumentedNode[]; + nodesCount: number; +}; + +/** The documentation report has all the documentation data for each coverage type. */ +export type DocumentationReport = Record; + +/** The processed documentation result has the documentation data for each coverage type and with coverage stats. */ +export type DocumentationCoverageReport = Record< + CoverageType, + DocumentationData & { + coverage: number; + } +>; diff --git a/packages/plugin-doc-coverage/src/lib/runner/runner.ts b/packages/plugin-doc-coverage/src/lib/runner/runner.ts new file mode 100644 index 000000000..8c059aac7 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/runner.ts @@ -0,0 +1,53 @@ +import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; +import type { DocCoveragePluginConfig } from '../config.js'; +import { processDocCoverage } from './doc-processer.js'; +import type { DocumentationCoverageReport } from './models.js'; + +export function createRunnerFunction( + config: DocCoveragePluginConfig, +): RunnerFunction { + return (): AuditOutputs => { + const coverageResult = processDocCoverage(config); + return trasformCoverageReportToAudits(coverageResult, config); + }; +} + +/** + * Transforms the coverage report into audit outputs. + * @param coverageResult - The coverage result containing undocumented items and coverage statistics + * @param options - Configuration options specifying which audits to include and exclude + * @returns Audit outputs with coverage scores and details about undocumented items + */ +export function trasformCoverageReportToAudits( + coverageResult: DocumentationCoverageReport, + options: Pick, +): AuditOutputs { + return Object.entries(coverageResult) + .filter(([type]) => { + const auditSlug = `${type}-coverage`; + if (options.onlyAudits?.length) { + return options.onlyAudits.includes(auditSlug); + } + if (options.skipAudits?.length) { + return !options.skipAudits.includes(auditSlug); + } + return true; + }) + .map(([type, item]) => { + const { coverage, issues } = item; + + return { + slug: `${type}-coverage`, + value: issues.length, + score: coverage / 100, + displayValue: `${issues.length} undocumented ${type}`, + details: { + issues: item.issues.map(({ file, line }) => ({ + message: 'Missing documentation', + source: { file, position: { startLine: line } }, + severity: 'warning', + })), + }, + }; + }); +} diff --git a/packages/plugin-doc-coverage/src/lib/runner/runner.unit.test.ts b/packages/plugin-doc-coverage/src/lib/runner/runner.unit.test.ts new file mode 100644 index 000000000..081df241f --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/runner.unit.test.ts @@ -0,0 +1,75 @@ +import type { DocumentationCoverageReport } from './models.js'; +import { trasformCoverageReportToAudits } from './runner.js'; + +describe('trasformCoverageReportToAudits', () => { + const mockCoverageResult = { + functions: { + coverage: 75, + nodesCount: 4, + issues: [ + { + file: 'test.ts', + line: 10, + name: 'testFunction', + type: 'functions', + }, + ], + }, + classes: { + coverage: 100, + nodesCount: 2, + issues: [ + { + file: 'test.ts', + line: 10, + name: 'testClass', + type: 'classes', + }, + ], + }, + } as unknown as DocumentationCoverageReport; + + it('should return all audits from the coverage result when no filters are provided', () => { + const result = trasformCoverageReportToAudits(mockCoverageResult, {}); + expect(result.map(item => item.slug)).toStrictEqual([ + 'functions-coverage', + 'classes-coverage', + ]); + }); + + it('should filter audits when onlyAudits is provided', () => { + const result = trasformCoverageReportToAudits(mockCoverageResult, { + onlyAudits: ['functions-coverage'], + }); + expect(result).toHaveLength(1); + expect(result.map(item => item.slug)).toStrictEqual(['functions-coverage']); + }); + + it('should filter audits when skipAudits is provided', () => { + const result = trasformCoverageReportToAudits(mockCoverageResult, { + skipAudits: ['functions-coverage'], + }); + expect(result).toHaveLength(1); + expect(result.map(item => item.slug)).toStrictEqual(['classes-coverage']); + }); + + it('should handle properly empty coverage result', () => { + const result = trasformCoverageReportToAudits( + {} as unknown as DocumentationCoverageReport, + {}, + ); + expect(result).toEqual([]); + }); + + it('should handle coverage result with multiple issues and add them to the details.issue of the report', () => { + const expectedIssues = 2; + const result = trasformCoverageReportToAudits(mockCoverageResult, {}); + expect(result).toHaveLength(2); + expect( + result.reduce( + (acc, item) => acc + (item.details?.issues?.length ?? 0), + 0, + ), + ).toBe(expectedIssues); + }); +}); diff --git a/packages/plugin-doc-coverage/src/lib/runner/utils.ts b/packages/plugin-doc-coverage/src/lib/runner/utils.ts new file mode 100644 index 000000000..298b64806 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/utils.ts @@ -0,0 +1,80 @@ +import { SyntaxKind } from 'ts-morph'; +import type { + CoverageType, + DocumentationCoverageReport, + DocumentationReport, +} from './models.js'; + +/** + * Creates an empty unprocessed coverage report. + * @returns The empty unprocessed coverage report. + */ +export function createEmptyCoverageData(): DocumentationReport { + return { + enums: { nodesCount: 0, issues: [] }, + interfaces: { nodesCount: 0, issues: [] }, + types: { nodesCount: 0, issues: [] }, + functions: { nodesCount: 0, issues: [] }, + variables: { nodesCount: 0, issues: [] }, + classes: { nodesCount: 0, issues: [] }, + methods: { nodesCount: 0, issues: [] }, + properties: { nodesCount: 0, issues: [] }, + }; +} + +/** + * Calculates the coverage percentage for each coverage type. + * @param result - The unprocessed coverage result. + * @returns The processed coverage result. + */ +export function calculateCoverage(result: DocumentationReport) { + return Object.fromEntries( + Object.entries(result).map(([key, value]) => { + const type = key as CoverageType; + return [ + type, + { + coverage: + value.nodesCount === 0 + ? 100 + : Number( + ((1 - value.issues.length / value.nodesCount) * 100).toFixed( + 2, + ), + ), + issues: value.issues, + nodesCount: value.nodesCount, + }, + ]; + }), + ) as DocumentationCoverageReport; +} + +/** + * Maps the SyntaxKind from the library ts-morph to the coverage type. + * @param kind - The SyntaxKind from the library ts-morph. + * @returns The coverage type. + */ +export function getCoverageTypeFromKind(kind: SyntaxKind): CoverageType { + switch (kind) { + case SyntaxKind.ClassDeclaration: + return 'classes'; + case SyntaxKind.MethodDeclaration: + return 'methods'; + case SyntaxKind.FunctionDeclaration: + return 'functions'; + case SyntaxKind.InterfaceDeclaration: + return 'interfaces'; + case SyntaxKind.EnumDeclaration: + return 'enums'; + case SyntaxKind.VariableStatement: + case SyntaxKind.VariableDeclaration: + return 'variables'; + case SyntaxKind.PropertyDeclaration: + return 'properties'; + case SyntaxKind.TypeAliasDeclaration: + return 'types'; + default: + throw new Error(`Unsupported syntax kind: ${kind}`); + } +} diff --git a/packages/plugin-doc-coverage/src/lib/runner/utils.unit.test.ts b/packages/plugin-doc-coverage/src/lib/runner/utils.unit.test.ts new file mode 100644 index 000000000..fcf8e2f33 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/runner/utils.unit.test.ts @@ -0,0 +1,84 @@ +import { SyntaxKind } from 'ts-morph'; +import type { DocumentationReport } from './models.js'; +import { + calculateCoverage, + createEmptyCoverageData, + getCoverageTypeFromKind, +} from './utils.js'; + +describe('createEmptyCoverageData', () => { + it('should create an empty report with all categories initialized', () => { + const result = createEmptyCoverageData(); + + expect(result).toStrictEqual({ + enums: { nodesCount: 0, issues: [] }, + interfaces: { nodesCount: 0, issues: [] }, + types: { nodesCount: 0, issues: [] }, + functions: { nodesCount: 0, issues: [] }, + variables: { nodesCount: 0, issues: [] }, + classes: { nodesCount: 0, issues: [] }, + methods: { nodesCount: 0, issues: [] }, + properties: { nodesCount: 0, issues: [] }, + }); + }); +}); + +describe('calculateCoverage', () => { + it('should calculate 100% coverage when there are no nodes', () => { + const input = createEmptyCoverageData(); + const result = calculateCoverage(input); + + Object.values(result).forEach(category => { + expect(category.coverage).toBe(100); + expect(category.nodesCount).toBe(0); + expect(category.issues).toEqual([]); + }); + }); + + it('should calculate correct coverage percentage with issues', () => { + const input: DocumentationReport = { + ...createEmptyCoverageData(), + functions: { + nodesCount: 4, + issues: [ + { type: 'functions', line: 1, file: 'test.ts', name: 'fn1' }, + { type: 'functions', line: 2, file: 'test.ts', name: 'fn2' }, + ], + }, + classes: { + nodesCount: 4, + issues: [ + { type: 'classes', line: 1, file: 'test.ts', name: 'Class1' }, + { type: 'classes', line: 2, file: 'test.ts', name: 'Class2' }, + { type: 'classes', line: 3, file: 'test.ts', name: 'Class3' }, + ], + }, + }; + + const result = calculateCoverage(input); + + expect(result.functions.coverage).toBe(50); + expect(result.classes.coverage).toBe(25); + }); +}); + +describe('getCoverageTypeFromKind', () => { + it.each([ + [SyntaxKind.ClassDeclaration, 'classes'], + [SyntaxKind.MethodDeclaration, 'methods'], + [SyntaxKind.FunctionDeclaration, 'functions'], + [SyntaxKind.InterfaceDeclaration, 'interfaces'], + [SyntaxKind.EnumDeclaration, 'enums'], + [SyntaxKind.VariableDeclaration, 'variables'], + [SyntaxKind.PropertyDeclaration, 'properties'], + [SyntaxKind.TypeAliasDeclaration, 'types'], + ])('should return %s for SyntaxKind.%s', (kind, expectedType) => { + expect(getCoverageTypeFromKind(kind)).toBe(expectedType); + }); + + it('should throw error for unsupported syntax kind', () => { + expect(() => getCoverageTypeFromKind(SyntaxKind.Unknown)).toThrow( + 'Unsupported syntax kind', + ); + }); +}); diff --git a/packages/plugin-doc-coverage/src/lib/utils.ts b/packages/plugin-doc-coverage/src/lib/utils.ts new file mode 100644 index 000000000..73e04c0af --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/utils.ts @@ -0,0 +1,52 @@ +import type { Audit, Group } from '@code-pushup/models'; +import type { DocCoveragePluginConfig } from './config.js'; +import { AUDITS_MAP } from './constants.js'; + +/** + * Get audits based on the configuration. + * If no audits are specified, return all audits. + * If audits are specified, return only the specified audits. + * @param config - The configuration object. + * @returns The audits. + */ +export function filterAuditsByPluginConfig( + config: Pick, +): Audit[] { + const { onlyAudits, skipAudits } = config; + + if (onlyAudits && onlyAudits.length > 0) { + return Object.values(AUDITS_MAP).filter(audit => + onlyAudits.includes(audit.slug), + ); + } + + if (skipAudits && skipAudits.length > 0) { + return Object.values(AUDITS_MAP).filter( + audit => !skipAudits.includes(audit.slug), + ); + } + + return Object.values(AUDITS_MAP); +} + +/** + * Filter groups by the audits that are specified in the configuration. + * The groups refs are filtered to only include the audits that are specified in the configuration. + * @param groups - The groups to filter. + * @param options - The configuration object containing either onlyAudits or skipAudits. + * @returns The filtered groups. + */ +export function filterGroupsByOnlyAudits( + groups: Group[], + options: Pick, +): Group[] { + const audits = filterAuditsByPluginConfig(options); + return groups + .map(group => ({ + ...group, + refs: group.refs.filter(ref => + audits.some(audit => audit.slug === ref.slug), + ), + })) + .filter(group => group.refs.length > 0); +} diff --git a/packages/plugin-doc-coverage/src/lib/utils.unit.test.ts b/packages/plugin-doc-coverage/src/lib/utils.unit.test.ts new file mode 100644 index 000000000..9252b5fd5 --- /dev/null +++ b/packages/plugin-doc-coverage/src/lib/utils.unit.test.ts @@ -0,0 +1,95 @@ +import type { Group } from '@code-pushup/models'; +import { AUDITS_MAP } from './constants.js'; +import { + filterAuditsByPluginConfig, + filterGroupsByOnlyAudits, +} from './utils.js'; + +describe('filterAuditsByPluginConfig', () => { + it('should return all audits when onlyAudits and skipAudits are not provided', () => { + const result = filterAuditsByPluginConfig({}); + expect(result).toStrictEqual(Object.values(AUDITS_MAP)); + }); + + it('should return all audits when onlyAudits is empty array and skipAudits is also empty array', () => { + const result = filterAuditsByPluginConfig({ + onlyAudits: [], + skipAudits: [], + }); + expect(result).toStrictEqual(Object.values(AUDITS_MAP)); + }); + + it('should return only specified audits when onlyAudits is provided', () => { + const onlyAudits = ['functions-coverage', 'classes-coverage']; + const result = filterAuditsByPluginConfig({ onlyAudits }); + + expect(result).toStrictEqual( + Object.values(AUDITS_MAP).filter(audit => + onlyAudits.includes(audit.slug), + ), + ); + }); + + it('should return only specified audits when skipAudits is provided', () => { + const skipAudits = ['functions-coverage', 'classes-coverage']; + const result = filterAuditsByPluginConfig({ skipAudits }); + expect(result).toStrictEqual( + Object.values(AUDITS_MAP).filter( + audit => !skipAudits.includes(audit.slug), + ), + ); + }); +}); + +describe('filterGroupsByOnlyAudits', () => { + const mockGroups: Group[] = [ + { + title: 'Group 1', + slug: 'group-1', + refs: [ + { slug: 'functions-coverage', weight: 1 }, + { slug: 'classes-coverage', weight: 1 }, + ], + }, + { + title: 'Group 2', + slug: 'group-2', + refs: [ + { slug: 'types-coverage', weight: 1 }, + { slug: 'interfaces-coverage', weight: 1 }, + ], + }, + ]; + + it('should return all groups when onlyAudits is not provided', () => { + const result = filterGroupsByOnlyAudits(mockGroups, {}); + expect(result).toStrictEqual(mockGroups); + }); + + it('should return all groups when onlyAudits is empty array', () => { + const result = filterGroupsByOnlyAudits(mockGroups, { onlyAudits: [] }); + expect(result).toStrictEqual(mockGroups); + }); + + it('should filter groups based on specified audits', () => { + const result = filterGroupsByOnlyAudits(mockGroups, { + onlyAudits: ['functions-coverage'], + }); + + expect(result).toStrictEqual([ + { + title: 'Group 1', + slug: 'group-1', + refs: [{ slug: 'functions-coverage', weight: 1 }], + }, + ]); + }); + + it('should remove groups with no matching refs', () => { + const result = filterGroupsByOnlyAudits(mockGroups, { + onlyAudits: ['enums-coverage'], + }); + + expect(result).toStrictEqual([]); + }); +}); diff --git a/packages/plugin-doc-coverage/tsconfig.json b/packages/plugin-doc-coverage/tsconfig.json new file mode 100644 index 000000000..893f9a925 --- /dev/null +++ b/packages/plugin-doc-coverage/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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.lib.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/packages/plugin-doc-coverage/tsconfig.lib.json b/packages/plugin-doc-coverage/tsconfig.lib.json new file mode 100644 index 000000000..37e86c560 --- /dev/null +++ b/packages/plugin-doc-coverage/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/lib/runner/doc-processer.js"], + "exclude": [ + "vite.config.unit.ts", + "vite.config.integration.ts", + "src/**/*.test.ts", + "src/**/*.mock.ts", + "mocks/**/*.ts" + ] +} diff --git a/packages/plugin-doc-coverage/tsconfig.test.json b/packages/plugin-doc-coverage/tsconfig.test.json new file mode 100644 index 000000000..9f29d6bb0 --- /dev/null +++ b/packages/plugin-doc-coverage/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + }, + "include": [ + "vite.config.unit.ts", + "vite.config.integration.ts", + "mocks/**/*.ts", + "src/**/*.test.ts" + ] +} diff --git a/packages/plugin-doc-coverage/vite.config.integration.ts b/packages/plugin-doc-coverage/vite.config.integration.ts new file mode 100644 index 000000000..b04d62c56 --- /dev/null +++ b/packages/plugin-doc-coverage/vite.config.integration.ts @@ -0,0 +1,29 @@ +/// +import { defineConfig } from 'vite'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-coverage', + test: { + reporters: ['basic'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-doc-coverage/integration-tests', + exclude: ['mocks/**', '**/types.ts'], + }, + environment: 'node', + include: ['src/**/*.integration.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + globalSetup: ['../../global-setup.ts'], + setupFiles: [ + '../../testing/test-setup/src/lib/console.mock.ts', + '../../testing/test-setup/src/lib/reset.mocks.ts', + ], + }, +}); diff --git a/packages/plugin-doc-coverage/vite.config.unit.ts b/packages/plugin-doc-coverage/vite.config.unit.ts new file mode 100644 index 000000000..3cae73a58 --- /dev/null +++ b/packages/plugin-doc-coverage/vite.config.unit.ts @@ -0,0 +1,31 @@ +/// +import { defineConfig } from 'vite'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-coverage', + test: { + reporters: ['basic'], + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-doc-coverage/unit-tests', + exclude: ['mocks/**', '**/types.ts'], + }, + environment: 'node', + include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + globalSetup: ['../../global-setup.ts'], + setupFiles: [ + '../../testing/test-setup/src/lib/cliui.mock.ts', + '../../testing/test-setup/src/lib/fs.mock.ts', + '../../testing/test-setup/src/lib/console.mock.ts', + '../../testing/test-setup/src/lib/reset.mocks.ts', + ], + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index d088eca5a..026003f3e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,9 @@ "@code-pushup/cli": ["packages/cli/src/index.ts"], "@code-pushup/core": ["packages/core/src/index.ts"], "@code-pushup/coverage-plugin": ["packages/plugin-coverage/src/index.ts"], + "@code-pushup/doc-coverage-plugin": [ + "packages/plugin-doc-coverage/src/index.ts" + ], "@code-pushup/eslint-plugin": ["packages/plugin-eslint/src/index.ts"], "@code-pushup/js-packages-plugin": [ "packages/plugin-js-packages/src/index.ts"