diff --git a/code-pushup.config.ts b/code-pushup.config.ts index bd089d884..9af68c6fb 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { coverageCoreConfigNx, eslintCoreConfigNx, + jsDocsCoreConfig, jsPackagesCoreConfig, lighthouseCoreConfig, } from './code-pushup.preset.js'; @@ -39,4 +40,12 @@ export default mergeConfigs( 'https://github.com/code-pushup/cli?tab=readme-ov-file#code-pushup-cli/', ), await eslintCoreConfigNx(), + jsDocsCoreConfig([ + '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..9b9c462f9 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -10,6 +10,14 @@ import eslintPlugin, { eslintConfigFromNxProject, } from './packages/plugin-eslint/src/index.js'; import jsPackagesPlugin from './packages/plugin-js-packages/src/index.js'; +import jsDocsPlugin, { + JsDocsPluginConfig, +} from './packages/plugin-jsdocs/src/index.js'; +import { + PLUGIN_SLUG, + groups, +} from './packages/plugin-jsdocs/src/lib/constants.js'; +import { filterGroupsByOnlyAudits } from './packages/plugin-jsdocs/src/lib/utils.js'; import lighthousePlugin, { lighthouseGroupRef, } from './packages/plugin-lighthouse/src/index.js'; @@ -82,6 +90,24 @@ export const eslintCategories: CategoryConfig[] = [ }, ]; +export function getJsDocsCategories( + config: JsDocsPluginConfig, +): CategoryConfig[] { + return [ + { + slug: 'docs', + title: 'Documentation', + 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,19 @@ export const lighthouseCoreConfig = async ( }; }; +export const jsDocsCoreConfig = ( + config: JsDocsPluginConfig | string[], +): CoreConfig => { + return { + plugins: [ + jsDocsPlugin(Array.isArray(config) ? { patterns: config } : config), + ], + categories: getJsDocsCategories( + Array.isArray(config) ? { patterns: config } : config, + ), + }; +}; + export const eslintCoreConfigNx = async ( projectName?: string, ): Promise => { diff --git a/e2e/plugin-jsdocs-e2e/eslint.config.js b/e2e/plugin-jsdocs-e2e/eslint.config.js new file mode 100644 index 000000000..2656b27cb --- /dev/null +++ b/e2e/plugin-jsdocs-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-jsdocs-e2e/mocks/fixtures/angular/code-pushup.config.ts b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/code-pushup.config.ts new file mode 100644 index 000000000..27c389120 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/code-pushup.config.ts @@ -0,0 +1,5 @@ +import jsDocsPlugin from '@code-pushup/jsdocs-plugin'; + +export default { + plugins: [jsDocsPlugin(['**/*.ts'])], +}; diff --git a/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.css b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.css new file mode 100644 index 000000000..f3958f2b4 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.css @@ -0,0 +1,4 @@ +h1 { + color: #336699; + text-align: center; +} diff --git a/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.html b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.html new file mode 100644 index 000000000..b6515528b --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.html @@ -0,0 +1 @@ +

{{ title }}

diff --git a/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.spec.ts b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.spec.ts new file mode 100644 index 000000000..c89f47dd8 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.spec.ts @@ -0,0 +1,3 @@ +function notRealisticFunction() { + return 'notRealisticFunction'; +} diff --git a/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.ts b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/app.component.ts new file mode 100644 index 000000000..2fc2b165f --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/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/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/map-event.function.ts b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/map-event.function.ts new file mode 100644 index 000000000..55f343e7c --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/angular/src/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/e2e/plugin-jsdocs-e2e/mocks/fixtures/react/code-pushup.config.ts b/e2e/plugin-jsdocs-e2e/mocks/fixtures/react/code-pushup.config.ts new file mode 100644 index 000000000..27c389120 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/mocks/fixtures/react/code-pushup.config.ts @@ -0,0 +1,5 @@ +import jsDocsPlugin from '@code-pushup/jsdocs-plugin'; + +export default { + plugins: [jsDocsPlugin(['**/*.ts'])], +}; diff --git a/e2e/plugin-jsdocs-e2e/mocks/fixtures/react/component.js b/e2e/plugin-jsdocs-e2e/mocks/fixtures/react/component.js new file mode 100644 index 000000000..dfa1336e3 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/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/e2e/plugin-jsdocs-e2e/project.json b/e2e/plugin-jsdocs-e2e/project.json new file mode 100644 index 000000000..88d2c308b --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/project.json @@ -0,0 +1,23 @@ +{ + "name": "plugin-jsdocs-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/plugin-jsdocs-e2e/src", + "projectType": "application", + "tags": ["scope:plugin", "type:e2e"], + "implicitDependencies": ["cli", "plugin-jsdocs"], + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["e2e/plugin-jsdocs-e2e/**/*.ts"] + } + }, + "e2e": { + "executor": "@nx/vite:test", + "options": { + "configFile": "e2e/plugin-jsdocs-e2e/vite.config.e2e.ts" + } + } + } +} diff --git a/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.json b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.json new file mode 100644 index 000000000..08d4dc630 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.json @@ -0,0 +1,197 @@ +{ + "packageName": "@code-pushup/core", + "plugins": [ + { + "title": "JSDoc coverage", + "slug": "jsdocs", + "icon": "folder-docs", + "description": "Official Code PushUp JSDoc coverage plugin.", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/jsdocs-plugin/", + "groups": [ + { + "slug": "documentation-coverage", + "refs": [ + { + "slug": "classes-coverage", + "weight": 2 + }, + { + "slug": "methods-coverage", + "weight": 2 + }, + { + "slug": "functions-coverage", + "weight": 2 + }, + { + "slug": "interfaces-coverage", + "weight": 1 + }, + { + "slug": "variables-coverage", + "weight": 1 + }, + { + "slug": "properties-coverage", + "weight": 1 + }, + { + "slug": "types-coverage", + "weight": 1 + }, + { + "slug": "enums-coverage", + "weight": 1 + } + ], + "title": "Documentation coverage", + "description": "Documentation coverage" + } + ], + "audits": [ + { + "slug": "enums-coverage", + "displayValue": "0 undocumented enums", + "value": 0, + "score": 1, + "details": { + "issues": [] + }, + "title": "Enums coverage", + "description": "Documentation coverage of enums" + }, + { + "slug": "interfaces-coverage", + "displayValue": "0 undocumented interfaces", + "value": 0, + "score": 1, + "details": { + "issues": [] + }, + "title": "Interfaces coverage", + "description": "Documentation coverage of interfaces" + }, + { + "slug": "types-coverage", + "displayValue": "0 undocumented types", + "value": 0, + "score": 1, + "details": { + "issues": [] + }, + "title": "Types coverage", + "description": "Documentation coverage of types" + }, + { + "slug": "functions-coverage", + "displayValue": "2 undocumented functions", + "value": 2, + "score": 0.3333, + "details": { + "issues": [ + { + "message": "Missing functions documentation for notRealisticFunction", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/app.component.spec.ts", + "position": { + "startLine": 1 + } + } + }, + { + "message": "Missing functions documentation for mapEventToCustomEvent", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/map-event.function.ts", + "position": { + "startLine": 3 + } + } + } + ] + }, + "title": "Functions coverage", + "description": "Documentation coverage of functions" + }, + { + "slug": "variables-coverage", + "displayValue": "1 undocumented variables", + "value": 1, + "score": 0, + "details": { + "issues": [ + { + "message": "Missing variables documentation for someVariable", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/map-event.function.ts", + "position": { + "startLine": 1 + } + } + } + ] + }, + "title": "Variables coverage", + "description": "Documentation coverage of variables" + }, + { + "slug": "classes-coverage", + "displayValue": "0 undocumented classes", + "value": 0, + "score": 1, + "details": { + "issues": [] + }, + "title": "Classes coverage", + "description": "Documentation coverage of classes" + }, + { + "slug": "methods-coverage", + "displayValue": "1 undocumented methods", + "value": 1, + "score": 0.5, + "details": { + "issues": [ + { + "message": "Missing methods documentation for sendEvent", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/app.component.ts", + "position": { + "startLine": 15 + } + } + } + ] + }, + "title": "Methods coverage", + "description": "Documentation coverage of methods" + }, + { + "slug": "properties-coverage", + "displayValue": "1 undocumented properties", + "value": 1, + "score": 0, + "details": { + "issues": [ + { + "message": "Missing properties documentation for title", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/app.component.ts", + "position": { + "startLine": 5 + } + } + } + ] + }, + "title": "Properties coverage", + "description": "Documentation coverage of properties" + } + ] + } + ] +} \ No newline at end of file diff --git a/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.txt b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.txt new file mode 100644 index 000000000..a44e639b3 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.txt @@ -0,0 +1,31 @@ +Code PushUp CLI +[ info ] Run collect... +Code PushUp Report - @code-pushup/core@0.57.0 + + +JSDoc coverage audits + +● Properties coverage 1 undocumented + properties +● Variables coverage 1 undocumented + variables +● Functions coverage 2 undocumented + functions +● Methods coverage 1 undocumented + methods +● ... 4 audits with perfect scores omitted for brevity ... + +Made with ❤ by code-pushup.dev + +[ success ] Collecting report successful! +[ info ] 💡 Configure categories to see the scores in an overview table. See: https://github.com/code-pushup/cli/blob/main/packages/cli/README.md +╭────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ 💡 Visualize your reports │ +│ │ +│ ❯ npx code-pushup upload - Run upload to upload the created report to the server │ +│ https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command │ +│ ❯ npx code-pushup autorun - Run collect & upload │ +│ https://github.com/code-pushup/cli/tree/main/packages/cli#autorun-command │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts b/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts new file mode 100644 index 000000000..499c02c20 --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts @@ -0,0 +1,72 @@ +import { cp } from 'node:fs/promises'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } 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, + removeColorCodes, +} from '@code-pushup/test-utils'; +import { executeProcess, readJsonFile } from '@code-pushup/utils'; + +describe('PLUGIN collect report with jsdocs-plugin NPM package', () => { + const fixturesDir = path.join( + 'e2e', + 'plugin-jsdocs-e2e', + 'mocks', + 'fixtures', + ); + const fixturesAngularDir = path.join(fixturesDir, 'angular'); + const fixturesReactDir = path.join(fixturesDir, 'react'); + + const envRoot = path.join( + E2E_ENVIRONMENTS_DIR, + nxTargetProject(), + TEST_OUTPUT_DIR, + ); + const angularDir = path.join(envRoot, 'angular'); + const reactDir = path.join(envRoot, 'react'); + const angularOutputDir = path.join(angularDir, '.code-pushup'); + const reactOutputDir = path.join(reactDir, '.code-pushup'); + + beforeAll(async () => { + await cp(fixturesAngularDir, angularDir, { recursive: true }); + await cp(fixturesReactDir, reactDir, { recursive: true }); + }); + + afterAll(async () => { + await teardownTestFolder(angularDir); + await teardownTestFolder(reactDir); + }); + + afterEach(async () => { + await teardownTestFolder(angularOutputDir); + await teardownTestFolder(reactOutputDir); + }); + + it('should run JSDoc plugin for Angular example dir and create report.json', async () => { + const { code, stdout } = await executeProcess({ + command: 'npx', + args: ['@code-pushup/cli', 'collect', '--no-progress'], + cwd: angularDir, + }); + + expect(code).toBe(0); + + expect(removeColorCodes(stdout)).toMatchFileSnapshot( + '__snapshots__/report.txt', + ); + + const report = await readJsonFile( + path.join(angularOutputDir, 'report.json'), + ); + + expect(() => reportSchema.parse(report)).not.toThrow(); + expect( + JSON.stringify(omitVariableReportData(report as Report), null, 2), + ).toMatchFileSnapshot('__snapshots__/report.json'); + }); +}); diff --git a/e2e/plugin-jsdocs-e2e/tsconfig.json b/e2e/plugin-jsdocs-e2e/tsconfig.json new file mode 100644 index 000000000..f5a2f890a --- /dev/null +++ b/e2e/plugin-jsdocs-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-jsdocs-e2e/tsconfig.test.json b/e2e/plugin-jsdocs-e2e/tsconfig.test.json new file mode 100644 index 000000000..34f35e30f --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"], + "target": "ES2020" + }, + "exclude": ["__test-env__/**"], + "include": [ + "vite.config.e2e.ts", + "tests/**/*.e2e.test.ts", + "tests/**/*.d.ts", + "mocks/**/*.ts" + ] +} diff --git a/e2e/plugin-jsdocs-e2e/vite.config.e2e.ts b/e2e/plugin-jsdocs-e2e/vite.config.e2e.ts new file mode 100644 index 000000000..f592cebec --- /dev/null +++ b/e2e/plugin-jsdocs-e2e/vite.config.e2e.ts @@ -0,0 +1,21 @@ +/// +import { defineConfig } from 'vite'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-jsdocs-e2e', + test: { + reporters: ['basic'], + testTimeout: 120_000, + globals: true, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + 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/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-jsdocs/README.md b/packages/plugin-jsdocs/README.md new file mode 100644 index 000000000..67617b4d7 --- /dev/null +++ b/packages/plugin-jsdocs/README.md @@ -0,0 +1,241 @@ +# @code-pushup/jsdocs-plugin + +[![npm](https://img.shields.io/npm/v/%40code-pushup%2Fjsdocs-plugin.svg)](https://www.npmjs.com/package/@code-pushup/jsdocs-plugin) +[![downloads](https://img.shields.io/npm/dm/%40code-pushup%2Fjsdocs-plugin)](https://npmtrends.com/@code-pushup/jsdocs-plugin) +[![dependencies](https://img.shields.io/librariesio/release/npm/%40code-pushup%2Fjsdocs-plugin)](https://www.npmjs.com/package/@code-pushup/jsdocs-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 -> 4/8 +- 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/jsdocs-plugin + ``` + + ```sh + yarn add --dev @code-pushup/jsdocs-plugin + ``` + + ```sh + pnpm add --save-dev @code-pushup/jsdocs-plugin + ``` + +3. Add this plugin to the `plugins` array in your Code PushUp CLI config file (e.g. `code-pushup.config.ts`). + + ```js + import jsDocsPlugin from '@code-pushup/jsdocs-plugin'; + + export default { + // ... + plugins: [ + // ... + jsDocsPlugin(['**/*.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: 'jsdocs', + slug: 'jsdocs', + 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: + +#### patterns + +Required parameter. The `patterns` 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 +jsDocsPlugin({ + patterns: [ + '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 +jsDocsPlugin({ + patterns: ['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 +jsDocsPlugin({ + patterns: ['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: 'jsdocs', + slug: 'jsdocs', + 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: 'jsdocs', + slug: 'class-jsdocs', + weight: 2, + }, + { + type: 'audit', + plugin: 'jsdocs', + slug: 'function-jsdocs', + 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/jsdocs-plugin", + "version": "0.57.0", + "title": "Documentation coverage", + "slug": "jsdocs", + "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/jsdocs-plugin/", + "groups": [ + { + "slug": "jsdocs", + "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-jsdocs/eslint.config.js b/packages/plugin-jsdocs/eslint.config.js new file mode 100644 index 000000000..40165321a --- /dev/null +++ b/packages/plugin-jsdocs/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-jsdocs/mocks/fixtures/filled-documentation/classes-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/classes-coverage.ts new file mode 100644 index 000000000..861060a00 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/classes-coverage.ts @@ -0,0 +1,4 @@ +/** + * A class that serves as an example for documentation coverage testing + */ +export class ExampleClass {} diff --git a/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/enums-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/enums-coverage.ts new file mode 100644 index 000000000..1c65b280c --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/enums-coverage.ts @@ -0,0 +1,4 @@ +/** + * Example enumeration representing different states or options + */ +export enum ExampleEnum {} diff --git a/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/interfaces-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/interfaces-coverage.ts new file mode 100644 index 000000000..067665a85 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/interfaces-coverage.ts @@ -0,0 +1,4 @@ +/** + * Interface representing a basic data structure + */ +export interface ExampleInterface {} diff --git a/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/methods-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/methods-coverage.ts new file mode 100644 index 000000000..1905f962b --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/methods-coverage.ts @@ -0,0 +1,10 @@ +/** Example class */ +export class Container { + /** + * An example method that returns a string + * @returns A string with the value 'exampleMethod' + */ + exampleMethod(): string { + return 'exampleMethod'; + } +} diff --git a/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/properties-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/properties-coverage.ts new file mode 100644 index 000000000..df7b35603 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/properties-coverage.ts @@ -0,0 +1,8 @@ +/** Example class */ +export class ExampleWithProperties { + /** + * Internal identifier that can only be accessed within this class + * @private + */ + private readonly exampleProperty = '123'; +} diff --git a/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/types-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/types-coverage.ts new file mode 100644 index 000000000..b45c56eeb --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/types-coverage.ts @@ -0,0 +1,4 @@ +/** + * A simple union type that can be either string or number + */ +export type ExampleType = string | number; diff --git a/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/variables-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/variables-coverage.ts new file mode 100644 index 000000000..e77a9afe8 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/filled-documentation/variables-coverage.ts @@ -0,0 +1,4 @@ +/** + * A constant string variable used for example purposes + */ +export const exampleVariable = 'example'; diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/classes-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/classes-coverage.ts new file mode 100644 index 000000000..5be78a174 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/classes-coverage.ts @@ -0,0 +1 @@ +export class ExampleClass {} diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/enums-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/enums-coverage.ts new file mode 100644 index 000000000..80bd662db --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/enums-coverage.ts @@ -0,0 +1 @@ +export enum ExampleEnum {} diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/functions-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/functions-coverage.ts new file mode 100644 index 000000000..6f54d9672 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/functions-coverage.ts @@ -0,0 +1,3 @@ +export function exampleFunction() { + return 'exampleFunction'; +} diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/interfaces-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/interfaces-coverage.ts new file mode 100644 index 000000000..0c082001b --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/interfaces-coverage.ts @@ -0,0 +1 @@ +export interface ExampleInterface {} diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/methods-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/methods-coverage.ts new file mode 100644 index 000000000..19920937f --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/methods-coverage.ts @@ -0,0 +1,8 @@ +/** + * A class that serves as an example for documentation coverage testing + */ +export class ExampleClass { + exampleMethod(): string { + return 'exampleMethod'; + } +} diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/properties-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/properties-coverage.ts new file mode 100644 index 000000000..48b1254d0 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/properties-coverage.ts @@ -0,0 +1,6 @@ +/** + * A class that serves as an example for documentation coverage testing + */ +export class ExampleWithProperties { + private readonly exampleProperty = '123'; +} diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/types-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/types-coverage.ts new file mode 100644 index 000000000..b3c58ae9a --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/types-coverage.ts @@ -0,0 +1 @@ +export type ExampleType = string | number; diff --git a/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/variables-coverage.ts b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/variables-coverage.ts new file mode 100644 index 000000000..19084a27d --- /dev/null +++ b/packages/plugin-jsdocs/mocks/fixtures/missing-documentation/variables-coverage.ts @@ -0,0 +1 @@ +export const exampleVariable = 'example'; diff --git a/packages/plugin-jsdocs/mocks/node.mock.ts b/packages/plugin-jsdocs/mocks/node.mock.ts new file mode 100644 index 000000000..691c7ab26 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/node.mock.ts @@ -0,0 +1,43 @@ +import { SyntaxKind } from 'ts-morph'; +import type { CoverageType } from '../src/lib/runner/models.js'; + +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-jsdocs/mocks/source-files.mock.ts b/packages/plugin-jsdocs/mocks/source-files.mock.ts new file mode 100644 index 000000000..9ae9e7047 --- /dev/null +++ b/packages/plugin-jsdocs/mocks/source-files.mock.ts @@ -0,0 +1,42 @@ +import { + ClassDeclaration, + EnumDeclaration, + FunctionDeclaration, + InterfaceDeclaration, + SourceFile, + SyntaxKind, + TypeAliasDeclaration, + VariableStatement, +} from 'ts-morph'; +import type { CoverageType } from '../src/lib/runner/models.js'; +import { nodeMock } from './node.mock'; + +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; +} diff --git a/packages/plugin-jsdocs/package.json b/packages/plugin-jsdocs/package.json new file mode 100644 index 000000000..2cf5f125c --- /dev/null +++ b/packages/plugin-jsdocs/package.json @@ -0,0 +1,43 @@ +{ + "name": "@code-pushup/jsdocs-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-jsdocs#readme", + "bugs": { + "url": "https://github.com/code-pushup/cli/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3A\"🧩%20jsdocs-plugin\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/code-pushup/cli.git", + "directory": "packages/plugin-jsdocs" + }, + "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", + "@code-pushup/utils": "0.57.0", + "zod": "^3.22.4", + "ts-morph": "^24.0.0" + } +} diff --git a/packages/plugin-jsdocs/project.json b/packages/plugin-jsdocs/project.json new file mode 100644 index 000000000..d8534553e --- /dev/null +++ b/packages/plugin-jsdocs/project.json @@ -0,0 +1,42 @@ +{ + "name": "plugin-jsdocs", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugin-jsdocs/src", + "projectType": "library", + "tags": ["scope:plugin", "type:feature", "publishable"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/plugin-jsdocs", + "main": "packages/plugin-jsdocs/src/index.ts", + "tsConfig": "packages/plugin-jsdocs/tsconfig.lib.json", + "additionalEntryPoints": ["packages/plugin-jsdocs/src/bin.ts"], + "assets": ["packages/plugin-jsdocs/*.md"] + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "packages/plugin-jsdocs/**/*.ts", + "packages/plugin-jsdocs/package.json" + ] + } + }, + "unit-test": { + "executor": "@nx/vite:test", + "options": { + "configFile": "packages/plugin-jsdocs/vite.config.unit.ts" + } + }, + "integration-test": { + "executor": "@nx/vite:test", + "options": { + "configFile": "packages/plugin-jsdocs/vite.config.integration.ts" + } + } + } +} diff --git a/packages/plugin-jsdocs/src/index.ts b/packages/plugin-jsdocs/src/index.ts new file mode 100644 index 000000000..f0b65ec1c --- /dev/null +++ b/packages/plugin-jsdocs/src/index.ts @@ -0,0 +1,4 @@ +import { jsDocsPlugin } from './lib/jsdocs-plugin.js'; + +export default jsDocsPlugin; +export type { JsDocsPluginConfig } from './lib/config.js'; diff --git a/packages/plugin-jsdocs/src/lib/config.ts b/packages/plugin-jsdocs/src/lib/config.ts new file mode 100644 index 000000000..2b793591d --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/config.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +const patternsSchema = z.union([z.string(), z.array(z.string()).min(1)], { + description: 'Glob pattern to match source files to evaluate.', +}); + +const jsDocsTargetObjectSchema = 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.', + ), + patterns: patternsSchema, + }) + .refine(data => !(data.skipAudits && data.onlyAudits), { + message: "You can't define 'skipAudits' and 'onlyAudits' simultaneously", + path: ['skipAudits', 'onlyAudits'], + }); + +export const jsDocsPluginConfigSchema = z + .union([patternsSchema, jsDocsTargetObjectSchema]) + .transform(target => + typeof target === 'string' || Array.isArray(target) + ? { patterns: target } + : target, + ); + +/** Type of the config that is passed to the plugin */ +export type JsDocsPluginConfig = z.input; + +/** Same as JsDocsPluginConfig but processed so the config is already an object even if it was passed the array of patterns */ +export type JsDocsPluginTransformedConfig = z.infer< + typeof jsDocsPluginConfigSchema +>; diff --git a/packages/plugin-jsdocs/src/lib/config.unit.test.ts b/packages/plugin-jsdocs/src/lib/config.unit.test.ts new file mode 100644 index 000000000..38c3ddc42 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/config.unit.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { type JsDocsPluginConfig, jsDocsPluginConfigSchema } from './config.js'; + +describe('JsDocsPlugin Configuration', () => { + describe('jsDocsPluginConfigSchema', () => { + it('accepts a valid configuration', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + patterns: ['src/**/*.ts'], + onlyAudits: ['functions-coverage'], + } satisfies JsDocsPluginConfig), + ).not.toThrow(); + }); + + it('throws when skipAudits and onlyAudits are defined', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + patterns: ['src/**/*.ts'], + skipAudits: ['functions-coverage'], + onlyAudits: ['classes-coverage'], + }), + ).toThrow( + "You can't define 'skipAudits' and 'onlyAudits' simultaneously", + ); + }); + }); + + describe('patterns', () => { + it('accepts a valid patterns array', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + patterns: ['src/**/*.{ts,tsx}', '!**/*.spec.ts', '!**/*.test.ts'], + } satisfies JsDocsPluginConfig), + ).not.toThrow(); + }); + + it('accepts a valid patterns array directly', () => { + expect(() => + jsDocsPluginConfigSchema.parse([ + 'src/**/*.{ts,tsx}', + '!**/*.spec.ts', + '!**/*.test.ts', + ]), + ).not.toThrow(); + }); + + it('throws for invalid patterns type', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + patterns: 123, + }), + ).toThrow('Expected array'); + }); + }); + + describe('onlyAudits', () => { + it('accepts a valid `onlyAudits` array', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + onlyAudits: ['functions-coverage', 'classes-coverage'], + patterns: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('accepts empty array for onlyAudits', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + onlyAudits: [], + patterns: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('allows onlyAudits to be undefined', () => { + const result = jsDocsPluginConfigSchema.parse({ + patterns: ['src/**/*.ts'], + }); + expect(result.onlyAudits).toBeUndefined(); + }); + + it('throws for invalid onlyAudits type', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + onlyAudits: 'functions-coverage', + patterns: ['src/**/*.ts'], + }), + ).toThrow('Expected array'); + }); + + it('throws for array with non-string elements', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + onlyAudits: [123, true], + patterns: ['src/**/*.ts'], + }), + ).toThrow('Expected string, received number'); + }); + }); + + describe('skipAudits', () => { + it('accepts valid audit slugs array', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + skipAudits: ['functions-coverage', 'classes-coverage'], + patterns: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('accepts empty array for skipAudits', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + skipAudits: [], + patterns: ['src/**/*.ts'], + }), + ).not.toThrow(); + }); + + it('allows skipAudits to be undefined', () => { + const result = jsDocsPluginConfigSchema.parse({ + patterns: ['src/**/*.ts'], + }); + expect(result.skipAudits).toBeUndefined(); + }); + + it('throws for invalid skipAudits type', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + skipAudits: 'functions-coverage', + patterns: ['src/**/*.ts'], + }), + ).toThrow('Expected array'); + }); + + it('throws for array with non-string elements', () => { + expect(() => + jsDocsPluginConfigSchema.parse({ + skipAudits: [123, true], + patterns: ['src/**/*.ts'], + }), + ).toThrow('Expected string'); + }); + }); +}); diff --git a/packages/plugin-jsdocs/src/lib/constants.ts b/packages/plugin-jsdocs/src/lib/constants.ts new file mode 100644 index 000000000..558ef38de --- /dev/null +++ b/packages/plugin-jsdocs/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 = 'jsdocs'; + +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-jsdocs/src/lib/jsdocs-plugin.ts b/packages/plugin-jsdocs/src/lib/jsdocs-plugin.ts new file mode 100644 index 000000000..694d0c135 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/jsdocs-plugin.ts @@ -0,0 +1,46 @@ +import type { PluginConfig } from '@code-pushup/models'; +import { type JsDocsPluginConfig, jsDocsPluginConfigSchema } 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 = 'JSDoc coverage'; + +export const PLUGIN_DESCRIPTION = 'Official Code PushUp JSDoc coverage plugin.'; + +export const PLUGIN_DOCS_URL = + 'https://www.npmjs.com/package/@code-pushup/jsdocs-plugin/'; + +/** + * Instantiates Code PushUp documentation coverage plugin for core config. + * + * @example + * import jsDocsPlugin from '@code-pushup/jsdocs-plugin' + * + * export default { + * // ... core config ... + * plugins: [ + * // ... other plugins ... + * jsDocsPlugin(['**/*.ts']) + * ] + * } + * + * @returns Plugin configuration. + */ +export function jsDocsPlugin(config: JsDocsPluginConfig): PluginConfig { + const jsDocsConfig = jsDocsPluginConfigSchema.parse(config); + + return { + slug: PLUGIN_SLUG, + title: PLUGIN_TITLE, + icon: 'folder-docs', + description: PLUGIN_DESCRIPTION, + docsUrl: PLUGIN_DOCS_URL, + groups: filterGroupsByOnlyAudits(groups, jsDocsConfig), + audits: filterAuditsByPluginConfig(jsDocsConfig), + runner: createRunnerFunction(jsDocsConfig), + }; +} diff --git a/packages/plugin-jsdocs/src/lib/jsdocs-plugin.unit.test.ts b/packages/plugin-jsdocs/src/lib/jsdocs-plugin.unit.test.ts new file mode 100644 index 000000000..1be2c11fb --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/jsdocs-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, + jsDocsPlugin, +} from './jsdocs-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('jsDocsPlugin', () => { + it('should create a valid plugin config', () => { + expect( + jsDocsPlugin({ + patterns: ['src/**/*.ts', '!**/*.spec.ts', '!**/*.test.ts'], + }), + ).toStrictEqual( + expect.objectContaining({ + slug: PLUGIN_SLUG, + title: PLUGIN_TITLE, + icon: 'folder-docs', + 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(() => + jsDocsPlugin({ + // @ts-expect-error testing invalid config + patterns: 123, + }), + ).toThrow('Expected array, received number'); + }); + + it('should filter groups', () => { + const config = { patterns: ['src/**/*.ts'] }; + jsDocsPlugin(config); + + expect(filterGroupsByOnlyAudits).toHaveBeenCalledWith(groups, config); + }); + + it('should filter audits', async () => { + const config = { patterns: ['src/**/*.ts'] }; + jsDocsPlugin(config); + + expect(filterAuditsByPluginConfig).toHaveBeenCalledWith(config); + }); + + it('should forward options to runner function', async () => { + const config = { patterns: ['src/**/*.ts'] }; + jsDocsPlugin(config); + + expect(createRunnerFunction).toHaveBeenCalledWith(config); + }); +}); diff --git a/packages/plugin-jsdocs/src/lib/models.ts b/packages/plugin-jsdocs/src/lib/models.ts new file mode 100644 index 000000000..a407d2a74 --- /dev/null +++ b/packages/plugin-jsdocs/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-jsdocs/src/lib/runner/__snapshots__/doc-processor.unit.test.ts.snap b/packages/plugin-jsdocs/src/lib/runner/__snapshots__/doc-processor.unit.test.ts.snap new file mode 100644 index 000000000..3a9c6a965 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/__snapshots__/doc-processor.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-jsdocs/src/lib/runner/constants.ts b/packages/plugin-jsdocs/src/lib/runner/constants.ts new file mode 100644 index 000000000..bb0a5da9a --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/constants.ts @@ -0,0 +1,15 @@ +import { SyntaxKind } from 'ts-morph'; +import type { CoverageType } from './models.js'; + +/** Maps the SyntaxKind from the library ts-morph to the coverage type. */ +export const SYNTAX_COVERAGE_MAP = new Map([ + [SyntaxKind.ClassDeclaration, 'classes'], + [SyntaxKind.MethodDeclaration, 'methods'], + [SyntaxKind.FunctionDeclaration, 'functions'], + [SyntaxKind.InterfaceDeclaration, 'interfaces'], + [SyntaxKind.EnumDeclaration, 'enums'], + [SyntaxKind.VariableDeclaration, 'variables'], + [SyntaxKind.VariableStatement, 'variables'], + [SyntaxKind.PropertyDeclaration, 'properties'], + [SyntaxKind.TypeAliasDeclaration, 'types'], +]); diff --git a/packages/plugin-jsdocs/src/lib/runner/doc-processor.integration.test.ts b/packages/plugin-jsdocs/src/lib/runner/doc-processor.integration.test.ts new file mode 100644 index 000000000..bb023a6da --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/doc-processor.integration.test.ts @@ -0,0 +1,246 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { processJsDocs } from './doc-processor.js'; +import type { DocumentationData } from './models.js'; + +type DocumentationDataCovered = DocumentationData & { + coverage: number; +}; + +describe('processJsDocs', () => { + const fixturesDir = path.join( + fileURLToPath(path.dirname(import.meta.url)), + '../../../mocks/fixtures', + ); + + it('should detect undocumented class', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/classes-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.classes).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('classes-coverage.ts'), + type: 'classes', + name: 'ExampleClass', + line: 1, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented class', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/classes-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.classes).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); + + it('should detect undocumented method', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/methods-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.methods).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('methods-coverage.ts'), + type: 'methods', + name: 'exampleMethod', + line: 5, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented method', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/methods-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.methods).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); + + it('should detect undocumented interface', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/interfaces-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.interfaces).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('interfaces-coverage.ts'), + type: 'interfaces', + name: 'ExampleInterface', + line: 1, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented interface', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/interfaces-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.interfaces).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); + + it('should detect undocumented variable', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/variables-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.variables).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('variables-coverage.ts'), + type: 'variables', + name: 'exampleVariable', + line: 1, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented variable', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/variables-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.variables).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); + + it('should detect undocumented property', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/properties-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.properties).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('properties-coverage.ts'), + type: 'properties', + name: 'exampleProperty', + line: 5, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented property', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/properties-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.properties).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); + + it('should detect undocumented type', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/types-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.types).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('types-coverage.ts'), + type: 'types', + name: 'ExampleType', + line: 1, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented type', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/types-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.types).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); + + it('should detect undocumented enum', () => { + const sourcePath = path.join( + fixturesDir, + 'missing-documentation/enums-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.enums).toStrictEqual({ + coverage: 0, + nodesCount: 1, + issues: [ + { + file: expect.pathToEndWith('enums-coverage.ts'), + type: 'enums', + name: 'ExampleEnum', + line: 1, + }, + ], + } satisfies DocumentationDataCovered); + }); + + it('should detect documented enum', () => { + const sourcePath = path.join( + fixturesDir, + 'filled-documentation/enums-coverage.ts', + ); + const results = processJsDocs({ patterns: [sourcePath] }); + expect(results.enums).toStrictEqual({ + coverage: 100, + nodesCount: 1, + issues: [], + } satisfies DocumentationDataCovered); + }); +}); diff --git a/packages/plugin-jsdocs/src/lib/runner/doc-processor.ts b/packages/plugin-jsdocs/src/lib/runner/doc-processor.ts new file mode 100644 index 000000000..ba26d14e0 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/doc-processor.ts @@ -0,0 +1,176 @@ +import { + ClassDeclaration, + JSDoc, + Project, + SourceFile, + SyntaxKind, + VariableStatement, +} from 'ts-morph'; +import { objectFromEntries, objectToEntries } from '@code-pushup/utils'; +import type { JsDocsPluginTransformedConfig } from '../config.js'; +import type { + DocumentationCoverageReport, + DocumentationReport, +} from './models.js'; +import { + calculateCoverage, + createEmptyCoverageData, + getCoverageTypeFromKind, +} from './utils.js'; + +type Node = { + getKind: () => SyntaxKind; + getName: () => string | undefined; + getStartLineNumber: () => number; + getJsDocs: () => JSDoc[]; +}; + +/** + * Gets the variables information from the variable statements + * @param variableStatements - The variable statements to process + * @returns 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 config - The configuration object containing patterns to include for documentation analysis + * @returns Object containing coverage statistics and undocumented items + */ +export function processJsDocs( + config: JsDocsPluginTransformedConfig, +): DocumentationCoverageReport { + const project = new Project(); + project.addSourceFilesAtPaths(config.patterns); + 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 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 = getCoverageFromAllNodesOfFile( + allNodesFromFile, + filePath, + ); + + return mergeDocumentationReports( + coverageReportOfAllFiles, + coverageReportOfCurrentFile, + ); + }, + createEmptyCoverageData(), + ); + + return calculateCoverage(unprocessedCoverageReport); +} + +/** + * Gets the coverage from all nodes of a file + * @param nodes - The nodes to process + * @param filePath - The file path where the nodes are located + * @returns The coverage report for the nodes + */ +function getCoverageFromAllNodesOfFile(nodes: Node[], filePath: string) { + return nodes.reduce((acc: DocumentationReport, node: 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()); +} + +/** + * Merges two documentation results + * @param accumulatedReport - The first empty documentation result + * @param currentFileReport - The second documentation result + * @returns The merged documentation result + */ +export function mergeDocumentationReports( + accumulatedReport: DocumentationReport, + currentFileReport: Partial, +): DocumentationReport { + return objectFromEntries( + objectToEntries(accumulatedReport).map(([key, value]) => { + const node = value; + const type = key; + return [ + type, + { + nodesCount: + node.nodesCount + (currentFileReport[type]?.nodesCount ?? 0), + issues: [...node.issues, ...(currentFileReport[type]?.issues ?? [])], + }, + ]; + }), + ); +} + +/** + * Gets the nodes from a class + * @param classNodes - The class nodes to process + * @returns The nodes from the class + */ +export function getClassNodes(classNodes: ClassDeclaration[]) { + return classNodes.flatMap(classNode => [ + ...classNode.getMethods(), + ...classNode.getProperties(), + ]); +} diff --git a/packages/plugin-jsdocs/src/lib/runner/doc-processor.unit.test.ts b/packages/plugin-jsdocs/src/lib/runner/doc-processor.unit.test.ts new file mode 100644 index 000000000..4200216ad --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/doc-processor.unit.test.ts @@ -0,0 +1,311 @@ +import type { ClassDeclaration, VariableStatement } from 'ts-morph'; +import { nodeMock } from '../../../mocks/node.mock.js'; +import { sourceFileMock } from '../../../mocks/source-files.mock.js'; +import { + getAllNodesFromASourceFile, + getClassNodes, + getDocumentationReport, + getVariablesInformation, + mergeDocumentationReports, +} from './doc-processor.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-jsdocs/src/lib/runner/models.ts b/packages/plugin-jsdocs/src/lib/runner/models.ts new file mode 100644 index 000000000..6da638ece --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/models.ts @@ -0,0 +1,36 @@ +/** The possible coverage types for documentation analysis */ +export type CoverageType = + | 'classes' + | 'methods' + | 'functions' + | 'interfaces' + | 'enums' + | 'variables' + | 'properties' + | 'types'; + +/** 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-jsdocs/src/lib/runner/runner.integration.test.ts b/packages/plugin-jsdocs/src/lib/runner/runner.integration.test.ts new file mode 100644 index 000000000..38cc4b954 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/runner.integration.test.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { AUDITS_MAP } from '../constants.js'; +import { createRunnerFunction } from './runner.js'; + +describe('createRunnerFunction', () => { + const fixturesDir = path.join( + fileURLToPath(path.dirname(import.meta.url)), + '..', + '..', + '..', + 'mocks', + 'fixtures', + ); + + const AUDIT_SLUGS = Object.keys(AUDITS_MAP).map(key => [ + key.replace(/-coverage/g, ''), + ]); + + it.each(AUDIT_SLUGS)( + 'should generate issues for %s coverage if undocumented', + async coverageType => { + const filePath = path.join( + fixturesDir, + `missing-documentation/${coverageType}-coverage.ts`, + ); + const runnerFn = createRunnerFunction({ + patterns: [filePath], + }); + + const results = await runnerFn(() => void 0); + + expect( + results.find(({ slug }) => slug === `${coverageType}-coverage`), + ).toStrictEqual( + expect.objectContaining({ + slug: `${coverageType}-coverage`, + score: 0, + value: 1, + displayValue: `1 undocumented ${coverageType}`, + details: { + issues: [ + expect.objectContaining({ + message: expect.stringContaining( + `Missing ${coverageType} documentation for`, + ), + severity: 'warning', + source: { + file: expect.stringContaining(path.basename(filePath)), + position: { + startLine: expect.any(Number), + }, + }, + }), + ], + }, + }), + ); + }, + ); + + it.each(AUDIT_SLUGS)( + 'should not generate issues for %s coverage if documented', + async coverageType => { + const filePath = path.join( + fixturesDir, + `filled-documentation/${coverageType}-coverage.ts`, + ); + const runnerFn = createRunnerFunction({ + patterns: [filePath], + }); + + const results = await runnerFn(() => void 0); + + expect( + results.find(({ slug }) => slug === `${coverageType}-coverage`), + ).toStrictEqual( + expect.objectContaining({ + slug: `${coverageType}-coverage`, + score: 1, + value: 0, + displayValue: `0 undocumented ${coverageType}`, + details: { + issues: [], + }, + }), + ); + }, + ); + + it('should respect onlyAudits option', async () => { + const runnerFn = createRunnerFunction({ + patterns: [], + onlyAudits: ['classes-coverage', 'methods-coverage'], + }); + + const results = await runnerFn({} as any); + + expect(results).toHaveLength(2); + expect(results.map(audit => audit.slug)).toStrictEqual([ + 'classes-coverage', + 'methods-coverage', + ]); + }); + + it('should respect skipAudits option', async () => { + const runnerFn = createRunnerFunction({ + patterns: [], + skipAudits: ['classes-coverage', 'methods-coverage'], + }); + + const results = await runnerFn({} as any); + + const slugs = results.map(audit => audit.slug); + expect(slugs).not.toContain('classes-coverage'); + expect(slugs).not.toContain('methods-coverage'); + }); +}); diff --git a/packages/plugin-jsdocs/src/lib/runner/runner.ts b/packages/plugin-jsdocs/src/lib/runner/runner.ts new file mode 100644 index 000000000..811e751be --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/runner.ts @@ -0,0 +1,54 @@ +import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; +import type { JsDocsPluginTransformedConfig } from '../config.js'; +import { processJsDocs } from './doc-processor.js'; +import type { CoverageType, DocumentationCoverageReport } from './models.js'; +import { coverageTypeToAuditSlug } from './utils.js'; + +export function createRunnerFunction( + config: JsDocsPluginTransformedConfig, +): RunnerFunction { + return (): AuditOutputs => { + const coverageResult = processJsDocs(config); + return trasformCoverageReportToAuditOutputs(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 trasformCoverageReportToAuditOutputs( + coverageResult: DocumentationCoverageReport, + options: Pick, +): AuditOutputs { + return Object.entries(coverageResult) + .filter(([type]) => { + const auditSlug = coverageTypeToAuditSlug(type as CoverageType); + 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, name }) => ({ + message: `Missing ${type} documentation for ${name}`, + source: { file, position: { startLine: line } }, + severity: 'warning', + })), + }, + }; + }); +} diff --git a/packages/plugin-jsdocs/src/lib/runner/runner.unit.test.ts b/packages/plugin-jsdocs/src/lib/runner/runner.unit.test.ts new file mode 100644 index 000000000..dee74fdc9 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/runner.unit.test.ts @@ -0,0 +1,75 @@ +import type { DocumentationCoverageReport } from './models.js'; +import { trasformCoverageReportToAuditOutputs } 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 DocumentationCoverageReport; + + it('should return all audits from the coverage result when no filters are provided', () => { + const result = trasformCoverageReportToAuditOutputs(mockCoverageResult, {}); + expect(result.map(item => item.slug)).toStrictEqual([ + 'functions-coverage', + 'classes-coverage', + ]); + }); + + it('should filter audits when onlyAudits is provided', () => { + const result = trasformCoverageReportToAuditOutputs(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 = trasformCoverageReportToAuditOutputs(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 = trasformCoverageReportToAuditOutputs( + {} 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 = trasformCoverageReportToAuditOutputs(mockCoverageResult, {}); + expect(result).toHaveLength(2); + expect( + result.reduce( + (acc, item) => acc + (item.details?.issues?.length ?? 0), + 0, + ), + ).toBe(expectedIssues); + }); +}); diff --git a/packages/plugin-jsdocs/src/lib/runner/utils.ts b/packages/plugin-jsdocs/src/lib/runner/utils.ts new file mode 100644 index 000000000..7729ab407 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/runner/utils.ts @@ -0,0 +1,75 @@ +import { SyntaxKind } from 'ts-morph'; +import { SYNTAX_COVERAGE_MAP } from './constants.js'; +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: [] }, + }; +} + +/** + * Converts the coverage type to the audit slug. + * @param type - The coverage type. + * @returns The audit slug. + */ +export function coverageTypeToAuditSlug(type: CoverageType) { + return `${type}-coverage`; +} + +/** + * 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 { + const type = SYNTAX_COVERAGE_MAP.get(kind); + + if (!type) { + throw new Error(`Unsupported syntax kind: ${kind}`); + } + return type; +} diff --git a/packages/plugin-jsdocs/src/lib/runner/utils.unit.test.ts b/packages/plugin-jsdocs/src/lib/runner/utils.unit.test.ts new file mode 100644 index 000000000..fcf8e2f33 --- /dev/null +++ b/packages/plugin-jsdocs/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-jsdocs/src/lib/utils.ts b/packages/plugin-jsdocs/src/lib/utils.ts new file mode 100644 index 000000000..ce99d9c86 --- /dev/null +++ b/packages/plugin-jsdocs/src/lib/utils.ts @@ -0,0 +1,52 @@ +import type { Audit, Group } from '@code-pushup/models'; +import type { JsDocsPluginTransformedConfig } 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-jsdocs/src/lib/utils.unit.test.ts b/packages/plugin-jsdocs/src/lib/utils.unit.test.ts new file mode 100644 index 000000000..9252b5fd5 --- /dev/null +++ b/packages/plugin-jsdocs/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-jsdocs/tsconfig.json b/packages/plugin-jsdocs/tsconfig.json new file mode 100644 index 000000000..893f9a925 --- /dev/null +++ b/packages/plugin-jsdocs/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-jsdocs/tsconfig.lib.json b/packages/plugin-jsdocs/tsconfig.lib.json new file mode 100644 index 000000000..ef2f7e2b3 --- /dev/null +++ b/packages/plugin-jsdocs/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vite.config.unit.ts", + "vite.config.integration.ts", + "src/**/*.test.ts", + "src/**/*.mock.ts", + "mocks/**/*.ts" + ] +} diff --git a/packages/plugin-jsdocs/tsconfig.test.json b/packages/plugin-jsdocs/tsconfig.test.json new file mode 100644 index 000000000..9f29d6bb0 --- /dev/null +++ b/packages/plugin-jsdocs/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-jsdocs/vite.config.integration.ts b/packages/plugin-jsdocs/vite.config.integration.ts new file mode 100644 index 000000000..8923fa06f --- /dev/null +++ b/packages/plugin-jsdocs/vite.config.integration.ts @@ -0,0 +1,30 @@ +/// +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-jsdocs/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', + '../../testing/test-setup/src/lib/extend/path.matcher.ts', + ], + }, +}); diff --git a/packages/plugin-jsdocs/vite.config.unit.ts b/packages/plugin-jsdocs/vite.config.unit.ts new file mode 100644 index 000000000..048851c63 --- /dev/null +++ b/packages/plugin-jsdocs/vite.config.unit.ts @@ -0,0 +1,32 @@ +/// +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-jsdocs/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', + '../../testing/test-setup/src/lib/extend/path.matcher.ts', + ], + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index d088eca5a..0bbce1646 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,7 @@ "@code-pushup/js-packages-plugin": [ "packages/plugin-js-packages/src/index.ts" ], + "@code-pushup/jsdocs-plugin": ["packages/plugin-jsdocs/src/index.ts"], "@code-pushup/lighthouse-plugin": [ "packages/plugin-lighthouse/src/index.ts" ],