diff --git a/code-pushup.config.ts b/code-pushup.config.ts index b917977f3..cebcb5a3a 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -1,8 +1,14 @@ import 'dotenv/config'; +import { join } from 'node:path'; import { z } from 'zod'; import { + KNIP_PLUGIN_SLUG, + KNIP_RAW_REPORT_NAME, + KNIP_REPORT_NAME, fileSizePlugin, fileSizeRecommendedRefs, + knipCategoryGroupRef, + knipPlugin, packageJsonDocumentationGroupRef, packageJsonPerformanceGroupRef, packageJsonPlugin, @@ -78,6 +84,19 @@ const config: CoreConfig = { }), await lighthousePlugin('https://codepushup.dev/'), + + await knipPlugin({ + rawOutputFile: join( + '.code-pushup', + KNIP_PLUGIN_SLUG, + `${KNIP_RAW_REPORT_NAME.split('.').shift()}-${Date.now()}.json`, + ), + outputFile: join( + '.code-pushup', + KNIP_PLUGIN_SLUG, + `${KNIP_REPORT_NAME.split('.').shift()}-${Date.now()}.json`, + ), + }), ], categories: [ @@ -168,6 +187,9 @@ const config: CoreConfig = { ...fileSizeRecommendedRefs, packageJsonPerformanceGroupRef, packageJsonDocumentationGroupRef, + knipCategoryGroupRef('files'), + knipCategoryGroupRef('dependencies'), + knipCategoryGroupRef('exports'), ], }, ], diff --git a/examples/plugins/mocks/knip-raw.ts b/examples/plugins/mocks/knip-raw.ts new file mode 100644 index 000000000..51dcd9678 --- /dev/null +++ b/examples/plugins/mocks/knip-raw.ts @@ -0,0 +1,138 @@ +import {ReporterOptions} from "knip"; + +export const rawReport: Pick = { + report: { + files: true, + dependencies: true, + devDependencies: true, + optionalPeerDependencies: true, + unlisted: true, + binaries: true, + unresolved: true, + exports: true, + nsExports: false, + types: true, + nsTypes: false, + enumMembers: true, + classMembers: false, + duplicates: true, + }, + issues: { + files: new Set(['code-pushup.json']), + dependencies: { + 'package.json': { + 'cli-table3': { + type: 'dependencies', + filePath: + '/Users/username/Projects/code-pushup/package.json', + symbol: 'cli-table3', + severity: 'error', + }, + } + }, + devDependencies: { + 'package.json': { + '@trivago/prettier-plugin-sort-imports': { + type: 'devDependencies', + filePath: + '/Users/username/Projects/code-pushup/package.json', + symbol: '@trivago/prettier-plugin-sort-imports', + severity: 'error', + }, + }, + }, + optionalPeerDependencies: { + 'package.json': { + 'ts-node': { + type: 'devDependencies', + filePath: + '/Users/username/Projects/code-pushup/package.json', + symbol: 'ts-node', + severity: 'error', + }, + }, + }, + unlisted: { + 'packages/plugin-lighthouse/.eslintrc.json': { + 'jsonc-eslint-parser': { + type: 'unlisted', + filePath: + '/Users/username/Projects/code-pushup/packages/plugin-lighthouse/.eslintrc.json', + symbol: 'jsonc-eslint-parser', + severity: 'error', + }, + }, + '.eslintrc.json': { + 'jsonc-eslint-parser': { + type: 'unlisted', + filePath: + '/Users/username/Projects/code-pushup/.eslintrc.json', + symbol: 'jsonc-eslint-parser', + severity: 'error', + }, + } + }, + binaries: {}, + unresolved: {}, + exports: { + 'packages/models/src/lib/category-config.ts': { + duplicateErrorMsg: { + type: 'exports', + filePath: + '/Users/username/Projects/code-pushup/packages/models/src/lib/category-config.ts', + symbol: 'duplicateErrorMsg', + symbolType: 'function', + pos: 1571, + line: 54, + col: 17, + severity: 'error', + }, + }, + }, + nsExports: {}, + types: { + 'packages/models/src/lib/group.ts': { + GroupMeta: { + type: 'types', + filePath: + '/Users/username/Projects/code-pushup/packages/models/src/lib/group.ts', + symbol: 'GroupMeta', + symbolType: 'type', + pos: 701, + line: 26, + col: 13, + severity: 'error', + }, + }, + }, + nsTypes: {}, + enumMembers: {}, + classMembers: {}, + duplicates: { + 'packages/nx-plugin/src/generators/init/generator.ts': { + 'initGenerator|default': { + type: 'duplicates', + filePath: + '/Users/username/Projects/code-pushup/packages/nx-plugin/src/generators/init/generator.ts', + symbol: 'initGenerator|default', + symbols: [ + { + symbol: 'initGenerator', + line: 76, + col: 2, + pos: 2144, + }, + { + symbol: 'default', + line: 91, + col: 15, + pos: 2479, + }, + ], + severity: 'error', + }, + }, + }, + }, + options: '', +}; diff --git a/examples/plugins/mocks/knip-report.ts b/examples/plugins/mocks/knip-report.ts new file mode 100644 index 000000000..bbec400cb --- /dev/null +++ b/examples/plugins/mocks/knip-report.ts @@ -0,0 +1,130 @@ +export const input = { + files: ['src/unused.ts'], + issues: [ + { + file: 'package.json', + owners: ['@org/admin'], + dependencies: ['jquery', 'moment'], + devDependencies: [], + unlisted: [{ name: 'react' }, { name: '@org/unresolved' }], + exports: [], + types: [], + duplicates: [], + }, + { + file: 'src/Registration.tsx', + owners: ['@org/owner'], + dependencies: [], + devDependencies: [], + binaries: [], + unresolved: [{ name: './unresolved', line: 8, col: 23, pos: 403 }], + exports: [{ name: 'unusedExport', line: 1, col: 14, pos: 13 }], + types: [ + { name: 'unusedEnum', line: 3, col: 13, pos: 71 }, + { name: 'unusedType', line: 8, col: 14, pos: 145 }, + ], + enumMembers: { + MyEnum: [ + { name: 'unusedMember', line: 13, col: 3, pos: 167 }, + { name: 'unusedKey', line: 15, col: 3, pos: 205 }, + ], + }, + classMembers: { + MyClass: [ + { name: 'unusedMember', line: 40, col: 3, pos: 687 }, + { name: 'unusedSetter', line: 61, col: 14, pos: 1071 }, + ], + }, + duplicates: ['Registration', 'default'], + }, + ], +}; + +export const output = [ + { + slug: 'unused-files', + value: 1, + displayValue: '1 unused files', + score: 0, + details: { + issues: [ + { + message: 'File "src/unused.ts" unused', + severity: 'warning', + source: { + file: 'src/unused.ts', + }, + }, + ], + }, + }, + { + slug: 'unlisted', + value: 2, + displayValue: '2 unlisted', + score: 0, + details: { + issues: [ + { + message: 'react', + severity: 'warning', + source: { + file: '???', + }, + }, + { + message: '@org/unresolved', + severity: 'warning', + source: { + file: '???', + }, + }, + ], + }, + }, + { + slug: 'dependencies', + value: 2, + displayValue: '2 dependencies', + score: 0, + details: { + issues: [ + { + message: 'jquery', + severity: 'warning', + source: { + file: 'package.json', + }, + }, + { + message: 'moment', + severity: 'warning', + source: { + file: 'package.json', + }, + }, + ], + }, + }, + { + slug: 'unresolved', + value: 2, + displayValue: '2 unresolved', + score: 0, + details: { + issues: [ + { + message: 'jquery', + severity: 'warning', + source: { + file: 'src/Registration.tsx', + position: { + startLine: 8, + startColumn: 23, + }, + }, + }, + ], + }, + }, +]; diff --git a/examples/plugins/project.json b/examples/plugins/project.json index f5eb63b79..131fe2948 100644 --- a/examples/plugins/project.json +++ b/examples/plugins/project.json @@ -12,6 +12,9 @@ "main": "examples/plugins/src/index.ts", "tsConfig": "examples/plugins/tsconfig.lib.json", "assets": ["examples/plugins/*.md"], + "additionalEntryPoints": [ + "examples/plugins/src/knip/src/reporter/index.ts" + ], "esbuildConfig": "esbuild.config.js" } }, diff --git a/examples/plugins/src/index.ts b/examples/plugins/src/index.ts index 4935449df..621a84b1a 100644 --- a/examples/plugins/src/index.ts +++ b/examples/plugins/src/index.ts @@ -16,3 +16,11 @@ export { LIGHTHOUSE_OUTPUT_FILE_DEFAULT, recommendedRefs as lighthouseCorePerfGroupRefs, } from './lighthouse/src/index'; +export { + knipPlugin, + knipCategoryAuditRef, + knipCategoryGroupRef, + KNIP_PLUGIN_SLUG, + KNIP_RAW_REPORT_NAME, + KNIP_REPORT_NAME, +} from './knip/src/index'; diff --git a/examples/plugins/src/knip/README.md b/examples/plugins/src/knip/README.md new file mode 100644 index 000000000..7bf02237f --- /dev/null +++ b/examples/plugins/src/knip/README.md @@ -0,0 +1,206 @@ +# knip + +🕵️ **Code PushUp plugin for detecting unused code** 📋 + +--- + +## Getting started + +1. If you haven't already, install [@code-pushup/cli](../cli/README.md) and create a configuration file. + +2. Copy the [plugin source](../knip) as is into your project + +3. Add this plugin to the `plugins` array in your Code PushUp CLI config file (e.g. `code-pushup.config.js`). + + Pass in the path on the directory to crawl (relative to `process.cwd()`), as well as patterns and a budget. + + ```js + import knipPlugin from './knip.plugin'; + + export default { + // ... + plugins: [ + // ... + knipPlugin({}), + ], + }; + ``` + +See a detailed guide on how to configure knip on their [official docs](https://knip.dev/guides/handling-issues) + +4. (Optional) Reference audits (or groups) that 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 audit and group should have on the overall category score (assign weight 0 to only include it for extra info, without influencing the category score). + + ```js + import { knipCategoryAuditRef, knipCategoryGroupRef } from './knip/index.ts'; + + export default { + // ... + categories: [ + // ... + { + slug: 'performance', + title: 'Performance', + refs: [...knipCategoryRefs], + }, + ], + }; + ``` + +5. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)). + +## Plugin Options + +[knip cli docs](https://knip.dev/reference/cli) + +## Configuration + +Knip should have a default configuration and therefore works without any config file. However, every project has its own style of organizing contextual files not directly related to the core logic (tooling, testing, measurement). +In such cases you have to [configure knip](https://knip.dev/overview/configuration) to align with your project. + +Create a `knip.config.ts` with the following content: + +```ts + +``` + +This file will automatically pick up by knip. See [location docs](https://knip.dev/overview/configuration#location) for details. + +Go on with customizing your `knip.config.ts` until the results look good to you. See [customize knip config](https://knip.dev/overview/configuration#customize) for details. + +## Configuration fo Nx + +In many cases projects use `Nx` to manage their single- or mono-repository. +This section covers approaches for a Nx setup. + +### use @beaussan/nx-knip helper + +Basic setup using he nx helper [`@beaussan/nx-knip`](https://github.com/beaussan/nx-tools/tree/main/packages/nx-knip). + +We would have to add quite some rules to the configuration file doing the setup manually: + +```ts +export default { + entry: [ + 'libs/lib-1/vitest.*.config.ts', + // ... + 'libs/lib-1/eslint.*.config.ts', + // ... + ], + project: [ + 'libs/lib-1/src/**/*.ts', + // ... + 'apps/app-1/src/**/*.ts', + // ... + ], +}; +``` + +With the helper it looks like this: + +```ts +import { combineNxKnipPlugins, withEsbuildApps, withEsbuildPublishableLibs, withEslint, withLocalNxPlugins, withNxTsPaths, withVitest } from '@beaussan/nx-knip'; + +export default combineNxKnipPlugins(withNxTsPaths(), withLocalNxPlugins({ pluginNames: ['nx-plugin'] }), withEsbuildApps(), withEsbuildPublishableLibs(), withVitest(), withEslint()); +``` + +### create custom helper with @beaussan/nx-knip + +```ts +export const withCustomNxStandards = (): KnipConfigPlugin => () => { + return { + project: ['**/*.{ts,js,tsx,jsx}'], + ignore: ['tmp/**', 'node_modules/**'], + entry: [ + // missing knip plugin for now, so this is in the root entry + 'code-pushup.config.ts', + 'tools/**/*.{js,mjs,ts,cjs,mts,cts}', + ], + ignoreDependencies: [ + 'prettier', + // this is used in a test for a case where we reference a non existing plugin + '@example/custom-plugin', + ], + }; +}; + +export default combineNxKnipPlugins( + // ... + withCustomNxStandards(), +); +``` + +## Audits + +Detailed information about the audits can be found in the docs folder of the plugin. +Audits are derived form knip's [issue types](https://knip.dev/reference/issue-types). + +**Legend** + +- Description + - 🔧 Auto-fixable issue types + - 🟠 Not included by default (include with filters) + - 📄 Source file given + - 📍 Position oin file given + +**Table of Audits** + +| Title | Description | Default On | Key | Source | Position | Fixable | +| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------- | ------------ | ------ | -------- | ------- | +| [Unused files](https://knip.dev/guides/handling-issues#unused-files) | Unable to find a reference to this file | | files | 📄 | | | +| [Unused dependencies](https://knip.dev/guides/handling-issues#unused-dependencies) | Unable to find a reference to this dependency | | dependencies | | | 🔧 | +| [Unused devDependencies](https://knip.dev/guides/handling-issues#unused-dependencies) | Unable to find a reference to this devDependency | | dependencies | | | 🔧 | +| [Unlisted dependencies](https://knip.dev/guides/handling-issues#unlisted-dependencies) | Used dependencies not listed in package.json | | unlisted | 📄 | 📍 | | +| [Unlisted binaries](https://knip.dev/guides/handling-issues) | Binaries from dependencies not listed in package.json | | binaries | 📄 | 📍 | | +| [Referenced optional peerDependencies](https://knip.dev/guides/handling-issues#referenced-optional-peerDependencies) | Optional peer dependency is referenced | | dependencies | 📄 | 📍 | | +| [Unresolved imports](https://knip.dev/guides/handling-issues#unresolved-imports) | Unable to resolve this (import) specifier | | unresolved | 📄 | 📍 | | +| [Unused exports](https://knip.dev/guides/handling-issues#unused-exports) | Unable to find a reference to this export | | exports | 📄 | 📍 | 🔧 | +| [Unused exported types](https://knip.dev/guides/handling-issues#unused-exports) | Unable to find a reference to this exported type | | types | 📄 | 📍 | 🔧 | +| [Exports in used namespace](https://knip.dev/guides/handling-issues#unused-exports) | Namespace with export is referenced, but not export itself | 🟠 | nsExports | 📄 | 📍 | | +| [Exported types in used namespace](https://knip.dev/guides/handling-issues#unused-exports) | Namespace with type is referenced, but not type itself | 🟠 | nsTypes | 📄 | 📍 | | +| [Unused exported enum members](https://knip.dev/guides/handling-issues#enum-members) | Unable to find a reference to this enum member | | enumMembers | 📄 | 📍 | | +| [Unused exported class members](https://knip.dev/guides/handling-issues#class-members) | Unable to find a reference to this class member | 🟠 | classMembers | 📄 | 📍 | | +| [Duplicate exports](https://knip.dev/guides/handling-issues) | This is exported more than once | | duplicates | 📄 | 📍 | | + +## Troubleshooting + +### Read the official documentation + +First you should get familiar with the official docs of knip: + +- [configuration](https://knip.dev/overview/configuration) +- [troubleshooting](https://knip.dev/guides/troubleshooting) + +### List dependency references for a specific package + +To list where your dependencies is used run the following command: + +``` +// list dependencies for certain package +npm list +// alias +npm ls +``` + +running `npm list jsonc-eslint-parser` could print the following: + +- + +```bash +@code-pushup/cli-source@0.29.0 /Users/name/projects/project-name +└─┬ @nx/eslint-plugin@17.3.2 + └── jsonc-eslint-parser@2.4.0 +``` + +This would mean that `jsonc-eslint-parser` is a sub dependency of `@nx/eslint-plugin` but not listed as dependency in your `package.json`. + +- + +```bash +├─┬ @nx/eslint-plugin@17.3.2 +│ └── jsonc-eslint-parser@2.4.0 deduped +└── jsonc-eslint-parser@2.4.0 +``` + +This would mean that `jsonc-eslint-parser` is a sub dependency of `@nx/eslint-plugin` and is listed as dependency in your `package.json`. diff --git a/examples/plugins/src/knip/src/constants.integration.test.ts b/examples/plugins/src/knip/src/constants.integration.test.ts new file mode 100644 index 000000000..f73a0bfbe --- /dev/null +++ b/examples/plugins/src/knip/src/constants.integration.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { auditSchema, groupSchema } from '@code-pushup/models'; +import { + KNIP_AUDITS, + KNIP_GROUP_ALL, + KNIP_GROUP_DEPENDENCIES, + KNIP_GROUP_EXPORTS, + KNIP_GROUP_FILES, +} from './constants'; + +describe('constants-AUDITS', () => { + it.each(KNIP_AUDITS.map(audit => [audit.slug, audit]))( + 'should be a valid %s audit meta info', + (_, audit) => { + expect(() => auditSchema.parse(audit)).not.toThrow(); + }, + ); +}); + +describe('constants-KNIP_GROUPS', () => { + it('should be a valid file group info', () => { + expect(() => groupSchema.parse(KNIP_GROUP_FILES)).not.toThrow(); + }); + + it('should be a valid exports group info', () => { + expect(() => groupSchema.parse(KNIP_GROUP_EXPORTS)).not.toThrow(); + }); + + it('should be a valid dependencies group info', () => { + expect(() => groupSchema.parse(KNIP_GROUP_DEPENDENCIES)).not.toThrow(); + }); + + it('should be a valid all group info', () => { + expect(() => groupSchema.parse(KNIP_GROUP_ALL)).not.toThrow(); + }); +}); diff --git a/examples/plugins/src/knip/src/constants.ts b/examples/plugins/src/knip/src/constants.ts new file mode 100644 index 000000000..a77a74a26 --- /dev/null +++ b/examples/plugins/src/knip/src/constants.ts @@ -0,0 +1,186 @@ +import { Audit, Group } from '@code-pushup/models'; + +export const KNIP_PLUGIN_SLUG = 'knip'; +export const KNIP_RAW_REPORT_NAME = 'knip-raw-report.json'; +export const KNIP_REPORT_NAME = 'knip-code-pushup-report.json'; + +const audits = [ + { + slug: 'files', + title: 'Unused Files', + description: 'Unable to find a reference to this file', + }, + { + slug: 'dependencies', + title: 'Unused Dependencies', + description: 'Unable to find a reference to this dependency', + }, + { + slug: 'devdependencies', + title: 'Unused Development Dependencies', + description: 'Unable to find a reference to this devDependency', + }, + { + slug: 'optionalpeerdependencies', + title: 'Referenced optional peerDependencies', + description: 'Optional peer dependency is referenced', + }, + { + slug: 'unlisted', + title: 'Unlisted dependencies', + description: 'Used dependencies not listed in package.json', + }, + { + slug: 'binaries', + title: 'Unlisted binaries', + description: 'Binaries from dependencies not listed in package.json', + }, + { + slug: 'unresolved', + title: 'Unresolved imports', + description: 'Unable to resolve this (import) specifier', + }, + { + slug: 'exports', + title: 'Unused exports', + description: 'Unable to find a reference to this export', + }, + { + slug: 'types', + title: 'Unused exported types', + description: 'Unable to find a reference to this exported type', + }, + { + slug: 'nsexports', + title: 'Exports in used namespace', + description: 'Namespace with export is referenced, but not export itself', + }, + { + slug: 'nstypes', + title: 'Exported types in used namespace', + description: 'Namespace with type is referenced, but not type itself', + }, + { + slug: 'enummembers', + title: 'Unused exported enum members', + description: 'Unable to find a reference to this enum member', + }, + { + slug: 'classmembers', + title: 'Unused exported class members', + description: 'Unable to find a reference to this class member', + }, + { + slug: 'duplicates', + title: 'Duplicate exports', + description: 'This is exported more than once', + }, +] as const satisfies Audit[]; // we use `as const satisfies` to get strict slug typing + +export type KnipAudits = (typeof audits)[number]['slug']; + +function docsLink(slug: KnipAudits): string { + // eslint-disable-next-line functional/no-let + let anchor = '#'; + const base = 'https://knip.dev/guides/handling-issues'; + + switch (slug) { + case 'files': + anchor = '#unused-files'; + break; + case 'dependencies': + case 'devdependencies': + anchor = '#unused-dependencies'; + break; + case 'unlisted': + anchor = '#unlisted-dependencies'; + break; + case 'optionalpeerdependencies': + anchor = '#referenced-optional-peerDependencies'; + break; + case 'unresolved': + anchor = '#unresolved-imports'; + break; + case 'exports': + case 'types': + case 'nsexports': + case 'nstypes': + anchor = '#unused-exports'; + break; + case 'enummembers': + anchor = '#enum-members'; + break; + case 'classmembers': + anchor = '#class-members'; + break; + // following cases also default: + // - case 'binaries': + // - case 'duplicates': + default: + return base; + } + + return `${base}${anchor}`; +} + +export const KNIP_AUDITS = audits.map(audit => ({ + ...audit, + docsUrl: docsLink(audit.slug), +})); + +export const KNIP_GROUP_FILES = { + slug: 'files', + title: 'All file audits', + description: 'Groups all file related audits', + refs: [{ slug: 'files', weight: 1 }], +} as const satisfies Group; + +export const KNIP_GROUP_DEPENDENCIES = { + slug: 'dependencies', + title: 'All dependency audits', + description: 'Groups all dependency related audits', + refs: [ + { slug: 'dependencies', weight: 1 }, + { slug: 'devdependencies', weight: 1 }, + { slug: 'binaries', weight: 1 }, + // critical as potentially breaking + { slug: 'optionalpeerdependencies', weight: 2 }, + { slug: 'unlisted', weight: 2 }, + ], +} as const satisfies Group; + +export const KNIP_GROUP_EXPORTS = { + slug: 'exports', + title: 'All exports related audits', + description: 'Groups all dependency related knip audits', + refs: [ + { slug: 'unresolved', weight: 10 }, + { slug: 'exports', weight: 10 }, + { slug: 'types', weight: 10 }, + { slug: 'nsexports', weight: 10 }, + { slug: 'nstypes', weight: 10 }, + { slug: 'enummembers', weight: 10 }, + { slug: 'classmembers', weight: 10 }, + { slug: 'duplicates', weight: 2 }, + ], +} as const satisfies Group; + +export const KNIP_GROUP_ALL = { + slug: 'all', + title: 'All knip audits', + description: 'Groups all knip audits into a group for easy use', + refs: [ + ...KNIP_GROUP_FILES.refs, + ...KNIP_GROUP_EXPORTS.refs, + ...KNIP_GROUP_DEPENDENCIES.refs, + ], +} as const satisfies Group; + +export const KNIP_GROUPS = [ + KNIP_GROUP_FILES, + KNIP_GROUP_EXPORTS, + KNIP_GROUP_DEPENDENCIES, + KNIP_GROUP_ALL, +] as const satisfies Group[]; // we use `as const satisfies` to get strict slug typing; + +export type KnipGroups = (typeof KNIP_GROUPS)[number]['slug']; diff --git a/examples/plugins/src/knip/src/index.ts b/examples/plugins/src/knip/src/index.ts new file mode 100644 index 000000000..1bc5ddf4c --- /dev/null +++ b/examples/plugins/src/knip/src/index.ts @@ -0,0 +1,16 @@ +import { knipPlugin } from './knip.plugin'; + +export { knipPlugin } from './knip.plugin'; + +export { + KNIP_GROUP_DEPENDENCIES, + KNIP_GROUP_FILES, + KNIP_GROUP_ALL, + KNIP_AUDITS, + KNIP_PLUGIN_SLUG, + KNIP_RAW_REPORT_NAME, + KNIP_REPORT_NAME, +} from './constants'; + +export { knipCategoryAuditRef, knipCategoryGroupRef } from './utils'; +export default knipPlugin; diff --git a/examples/plugins/src/knip/src/knip.plugin.ts b/examples/plugins/src/knip/src/knip.plugin.ts new file mode 100644 index 000000000..75f162478 --- /dev/null +++ b/examples/plugins/src/knip/src/knip.plugin.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; +import { PluginConfig } from '@code-pushup/models'; +import { KNIP_AUDITS, KNIP_GROUPS, KNIP_PLUGIN_SLUG } from './constants'; +import { RunnerOptions, createRunnerConfig } from './runner'; + +export type PluginOptions = RunnerOptions; + +export function knipPlugin(options: PluginOptions = {}): PluginConfig { + const { + outputFile = join( + '.code-pushup', + KNIP_PLUGIN_SLUG, + `knip-report-${Date.now()}.json`, + ), + ...runnerOptions + } = options; + return { + slug: KNIP_PLUGIN_SLUG, + title: 'Knip', + icon: 'folder-javascript', + description: 'A plugin to track dependencies and duplicates', + runner: createRunnerConfig({ + ...runnerOptions, + outputFile, + }), + audits: KNIP_AUDITS, + groups: KNIP_GROUPS, + }; +} diff --git a/examples/plugins/src/knip/src/knip.plugin.unit.test.ts b/examples/plugins/src/knip/src/knip.plugin.unit.test.ts new file mode 100644 index 000000000..ec6e0cc63 --- /dev/null +++ b/examples/plugins/src/knip/src/knip.plugin.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { pluginConfigSchema } from '@code-pushup/models'; +import { KNIP_AUDITS, KNIP_GROUPS, KNIP_PLUGIN_SLUG } from './constants'; +import { knipPlugin } from './knip.plugin'; + +describe('knipPlugin-create-config-object', () => { + it('should return valid PluginConfig', () => { + const pluginConfig = knipPlugin({}); + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + expect(pluginConfig).toEqual( + expect.objectContaining({ + slug: KNIP_PLUGIN_SLUG, + title: 'Knip', + icon: 'folder-javascript', + description: 'A plugin to track dependencies and duplicates', + audits: KNIP_AUDITS, + groups: KNIP_GROUPS, + }), + ); + }); +}); diff --git a/examples/plugins/src/knip/src/reporter/__snapshots__/reporter.unit.test.ts.snap b/examples/plugins/src/knip/src/reporter/__snapshots__/reporter.unit.test.ts.snap new file mode 100644 index 000000000..a06c4bb60 --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/__snapshots__/reporter.unit.test.ts.snap @@ -0,0 +1,164 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`knipReporter > should produce valid audit outputsass 1`] = ` +[ + { + "details": { + "issues": [ + { + "message": "Unused file code-pushup.json", + "severity": "info", + "source": { + "file": "../../../code-pushup.json", + }, + }, + ], + }, + "score": 0, + "slug": "unused-files", + "value": 1, + }, + { + "details": { + "issues": [ + { + "message": "Unused dependency cli-table3", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/package.json", + }, + }, + ], + }, + "score": 0, + "slug": "unused-dependencies", + "value": 1, + }, + { + "details": { + "issues": [ + { + "message": "Unused devDependency @trivago/prettier-plugin-sort-imports", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/package.json", + }, + }, + ], + }, + "score": 0, + "slug": "unused-devdependencies", + "value": 1, + }, + { + "details": { + "issues": [ + { + "message": "Referenced optional peerDependency ts-node", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/package.json", + }, + }, + ], + }, + "score": 0, + "slug": "referenced-optional-peerdependencies", + "value": 1, + }, + { + "details": { + "issues": [ + { + "message": "Unlisted dependency jsonc-eslint-parser", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/packages/plugin-lighthouse/.eslintrc.json", + }, + }, + { + "message": "Unlisted dependency jsonc-eslint-parser", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/.eslintrc.json", + }, + }, + ], + }, + "score": 0, + "slug": "unlisted-dependencies", + "value": 2, + }, + { + "score": 1, + "slug": "unlisted-binaries", + "value": 0, + }, + { + "score": 1, + "slug": "unresolved-imports", + "value": 0, + }, + { + "details": { + "issues": [ + { + "message": "Unused export duplicateErrorMsg", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/packages/models/src/lib/category-config.ts", + "position": { + "startColumn": 17, + "startLine": 54, + }, + }, + }, + ], + }, + "score": 0, + "slug": "unused-exports", + "value": 1, + }, + { + "details": { + "issues": [ + { + "message": "Unused exported type GroupMeta", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/packages/models/src/lib/group.ts", + "position": { + "startColumn": 13, + "startLine": 26, + }, + }, + }, + ], + }, + "score": 0, + "slug": "unused-exported-types", + "value": 1, + }, + { + "score": 1, + "slug": "unused-exported-enum-members", + "value": 0, + }, + { + "details": { + "issues": [ + { + "message": "Duplicate export initGenerator|default", + "severity": "error", + "source": { + "file": "/Users/username/Projects/code-pushup/packages/nx-plugin/src/generators/init/generator.ts", + }, + }, + ], + }, + "score": 0, + "slug": "duplicate-exports", + "value": 1, + }, +] +`; diff --git a/examples/plugins/src/knip/src/reporter/constants.ts b/examples/plugins/src/knip/src/reporter/constants.ts new file mode 100644 index 000000000..694d62d43 --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/constants.ts @@ -0,0 +1,62 @@ +import { IssueType } from 'knip/dist/types/issues'; + +export const ISSUE_TYPES = [ + 'files', + 'dependencies', + 'devDependencies', + 'optionalPeerDependencies', + 'unlisted', + 'binaries', + 'unresolved', + 'exports', + 'nsExports', + 'types', + 'nsTypes', + 'enumMembers', + 'classMembers', + 'duplicates', +] as const; + +export const ISSUE_TYPE_TITLE: Record = { + files: 'Unused files', + _files: 'Unused files', + dependencies: 'Unused dependencies', + devDependencies: 'Unused devDependencies', + optionalPeerDependencies: 'Referenced optional peerDependencies', + unlisted: 'Unlisted dependencies', + binaries: 'Unlisted binaries', + unresolved: 'Unresolved imports', + exports: 'Unused exports', + nsExports: 'Exports in used namespace', + types: 'Unused exported types', + nsTypes: 'Exported types in used namespace', + enumMembers: 'Unused exported enum members', + classMembers: 'Unused exported class members', + duplicates: 'Duplicate exports', +} as const; + +export const ISSUE_TYPE_MESSAGE: Record< + IssueType | '_files', + (arg: string) => string +> = { + files: (file: string) => `Unused file ${file}`, + // eslint-disable-next-line @typescript-eslint/naming-convention + _files: (file: string) => `Unused file ${file}`, + dependencies: (dep: string) => `Unused dependency ${dep}`, + devDependencies: (dep: string) => `Unused devDependency ${dep}`, + optionalPeerDependencies: (dep: string) => + `Referenced optional peerDependency ${dep}`, + unlisted: (dep: string) => `Unlisted dependency ${dep}`, + binaries: (binary: string) => `Unlisted binary ${binary}`, + unresolved: (importName: string) => `Unresolved import ${importName}`, + exports: (exportName: string) => `Unused export ${exportName}`, + nsExports: (namespace: string) => `Exports in used namespace ${namespace}`, + types: (type: string) => `Unused exported type ${type}`, + nsTypes: (namespace: string) => + `Exported types in used namespace ${namespace}`, + enumMembers: (enumMember: string) => + `Unused exported enum member ${enumMember}`, + classMembers: (classMember: string) => + `Unused exported class member ${classMember}`, + duplicates: (duplicate: string) => `Duplicate export ${duplicate}`, +} as const; diff --git a/examples/plugins/src/knip/src/reporter/index.ts b/examples/plugins/src/knip/src/reporter/index.ts new file mode 100644 index 000000000..46e91a720 --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/index.ts @@ -0,0 +1,5 @@ +import { knipReporter } from './reporter'; + +export default knipReporter; +export { knipReporter } from './reporter'; +export { CustomReporterOptions } from './model'; diff --git a/examples/plugins/src/knip/src/reporter/model.ts b/examples/plugins/src/knip/src/reporter/model.ts new file mode 100644 index 000000000..2d63040e6 --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/model.ts @@ -0,0 +1,34 @@ +import chalk from 'chalk'; +import { z } from 'zod'; +import { filePathSchema } from '@code-pushup/models'; + +export const customReporterOptionsSchema = z.object({ + outputFile: filePathSchema.optional(), + rawOutputFile: filePathSchema.optional(), +}); + +export type CustomReporterOptions = z.infer; + +export function parseCustomReporterOptions( + optionsString?: string, +): CustomReporterOptions { + // eslint-disable-next-line functional/no-let + let rawJson; + try { + rawJson = + typeof optionsString === 'string' && optionsString !== '' + ? (JSON.parse(optionsString) as Record) + : {}; + } catch (error) { + throw new Error(`The passed knip reporter options have to be a JSON parseable string. E.g. --reporter-options='{\\"prop\\":42}' + Option string: ${chalk.bold(optionsString)} + Error: ${(error as Error).message}`); + } + + try { + return customReporterOptionsSchema.parse(rawJson); + } catch (error) { + throw new Error(`The reporter options options have to follow the schema.' + Error: ${(error as Error).message}`); + } +} diff --git a/examples/plugins/src/knip/src/reporter/model.unit.test.ts b/examples/plugins/src/knip/src/reporter/model.unit.test.ts new file mode 100644 index 000000000..f3f76618f --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/model.unit.test.ts @@ -0,0 +1,45 @@ +import chalk from 'chalk'; +import { describe, expect, it } from 'vitest'; +import { CustomReporterOptions, parseCustomReporterOptions } from './model'; + +describe('parseCustomReporterOptions', () => { + it('should return empty object if no reporter options are given', () => { + expect(parseCustomReporterOptions()).toStrictEqual({}); + }); + + it('should return valid report options', () => { + expect( + parseCustomReporterOptions( + JSON.stringify({ + outputFile: 'my-knip-report.json', + rawOutputFile: 'my-knip-raw-report.json', + } satisfies CustomReporterOptions), + ), + ).toStrictEqual({ + outputFile: 'my-knip-report.json', + rawOutputFile: 'my-knip-raw-report.json', + }); + }); + + it('should throw for invalid reporter-options argument', () => { + expect(() => parseCustomReporterOptions('{asd')).toThrow( + `The passed knip reporter options have to be a JSON parseable string. E.g. --reporter-options='{\\"prop\\":42}'`, + ); + expect(() => parseCustomReporterOptions('{asd')).toThrow( + `Option string: ${chalk.bold('{asd')}`, + ); + expect(() => parseCustomReporterOptions('{asd')).toThrow( + `Error: Unexpected token a in JSON at position 1`, + ); + }); + + it('should throw for invalid options', () => { + const opt = JSON.stringify({ + outputFile: '', + } satisfies CustomReporterOptions); + expect(() => parseCustomReporterOptions(opt)).toThrow( + 'The reporter options options have to follow the schema.', + ); + expect(() => parseCustomReporterOptions(opt)).toThrow('path is invalid'); + }); +}); diff --git a/examples/plugins/src/knip/src/reporter/reporter.ts b/examples/plugins/src/knip/src/reporter/reporter.ts new file mode 100644 index 000000000..2031c36d0 --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/reporter.ts @@ -0,0 +1,38 @@ +import type { ReporterOptions } from 'knip'; +import { writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { ensureDirectoryExists, ui } from '@code-pushup/utils'; +import { KNIP_REPORT_NAME } from '../constants'; +import { parseCustomReporterOptions } from './model'; +import { DeepPartial } from './types'; +import { knipToCpReport } from './utils'; + +/** + * @example + * + * npx knip --reporter ./code-pushup.reporter.ts --reporter-options='{\"outputFile\":\"my-knip-report.json\"}' + * + */ +export const knipReporter = async ({ + report, + issues, + options, +}: DeepPartial) => { + const reporterOptions = parseCustomReporterOptions(options); + const { outputFile = KNIP_REPORT_NAME, rawOutputFile } = reporterOptions; + + if (rawOutputFile) { + await ensureDirectoryExists(dirname(rawOutputFile)); + await writeFile( + rawOutputFile, + JSON.stringify({ report, issues, options: reporterOptions }, null, 2), + ); + ui().logger.info(`Saved raw report to ${rawOutputFile}`); + } + + const result = await knipToCpReport({ issues, report }); + + await ensureDirectoryExists(dirname(outputFile)); + await writeFile(outputFile, JSON.stringify(result, null, 2)); + ui().logger.info(`Saved report to ${outputFile}`); +}; diff --git a/examples/plugins/src/knip/src/reporter/reporter.unit.test.ts b/examples/plugins/src/knip/src/reporter/reporter.unit.test.ts new file mode 100644 index 000000000..3822faabb --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/reporter.unit.test.ts @@ -0,0 +1,88 @@ +import type { ReporterOptions } from 'knip'; +import { fs } from 'memfs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { MEMFS_VOLUME, getLogMessages } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; +import { rawReport } from '../../../../mocks/knip-raw'; +import { KNIP_REPORT_NAME } from '../constants'; +import { CustomReporterOptions } from './model'; +import { knipReporter } from './reporter'; + +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + getGitRoot: vi.fn().mockResolvedValue('User/projects/code-pushup-cli/'), + }; +}); + +describe('knipReporter', () => { + it('should saves report to file system by default', async () => { + await expect( + knipReporter({ + report: { + files: true, + }, + issues: { + files: new Set(['main.js']), + }, + } as ReporterOptions), + ).resolves.toBeUndefined(); + + expect(getLogMessages(ui().logger)).toStrictEqual([ + '[ blue(info) ] Saved report to knip-code-pushup-report.json', + ]); + }); + + it('should accept outputFile', async () => { + await expect( + knipReporter({ + report: { + files: true, + }, + issues: { + files: new Set(['main.js']), + }, + options: JSON.stringify({ + outputFile: 'report.json', + } satisfies CustomReporterOptions), + } as ReporterOptions), + ).resolves.toBeUndefined(); + + expect(getLogMessages(ui().logger)).toStrictEqual([ + '[ blue(info) ] Saved report to report.json', + ]); + }); + + it('should accept rawOutputFile', async () => { + await expect( + knipReporter({ + report: { + files: true, + }, + issues: { + files: new Set(['main.js']), + }, + options: JSON.stringify({ + rawOutputFile: 'raw-report.json', + } satisfies CustomReporterOptions), + } as ReporterOptions), + ).resolves.toBeUndefined(); + + expect(getLogMessages(ui().logger).at(0)).toBe( + '[ blue(info) ] Saved raw report to raw-report.json', + ); + }); + + it('should produce valid audit outputsass', async () => { + await expect(knipReporter(rawReport)).resolves.toBeUndefined(); + + const auditOutputsContent = await fs.promises.readFile( + join(MEMFS_VOLUME, KNIP_REPORT_NAME), + { encoding: 'utf8' }, + ); + const auditOutputsJson = JSON.parse(auditOutputsContent.toString()); + expect(auditOutputsJson).toMatchSnapshot(); + }); +}); diff --git a/examples/plugins/src/knip/src/reporter/types.ts b/examples/plugins/src/knip/src/reporter/types.ts new file mode 100644 index 000000000..e422dd517 --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/types.ts @@ -0,0 +1,3 @@ +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; diff --git a/examples/plugins/src/knip/src/reporter/utils.ts b/examples/plugins/src/knip/src/reporter/utils.ts new file mode 100644 index 000000000..cc8f3f20d --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/utils.ts @@ -0,0 +1,97 @@ +import type { + IssueSet, + IssueType, + Issue as KnipIssue, + Issues as KnipIssues, + IssueSeverity as KnipSeverity, + ReporterOptions, +} from 'knip/dist/types/issues'; +import type { Entries } from 'type-fest'; +import { + AuditOutput, + AuditOutputs, + IssueSeverity as CondPushupIssueSeverity, + Issue as CpIssue, + Issue, +} from '@code-pushup/models'; +import { formatGitPath, getGitRoot, slugify } from '@code-pushup/utils'; +import { ISSUE_TYPE_MESSAGE, ISSUE_TYPE_TITLE } from './constants'; +import { DeepPartial } from './types'; + +const severityMap: Record = { + unknown: 'info', + off: 'info', + error: 'error', + warn: 'warning', +} as const; + +export function knipToCpReport({ + issues: rawIssues = {}, + report, +}: DeepPartial): Promise { + return Promise.all( + (Object.entries(report ?? {}) as Entries) + .filter(([_, isReportType]) => isReportType) + .map(async ([issueType]): Promise => { + const issues = await toIssues(issueType, rawIssues); + return { + slug: slugify( + ISSUE_TYPE_TITLE[issueType as keyof typeof ISSUE_TYPE_TITLE], + ), + score: issues.length === 0 ? 1 : 0, + value: issues.length, + ...(issues.length > 0 ? { details: { issues } } : {}), + }; + }), + ); +} + +export function getPosition(issue: KnipIssue) { + return issue.line && issue.col + ? { + startColumn: issue.col, + startLine: issue.line, + } + : false; +} + +export async function toIssues( + issueType: IssueType, + issues: DeepPartial, +): Promise { + const isSet = issues[issueType] instanceof Set; + const issuesForType: string[] | KnipIssue[] = isSet + ? [...(issues[issueType] as IssueSet)] + : Object.values(issues[issueType] as Issue).flatMap(Object.values); + + const gitRoot = await getGitRoot(); + if (issuesForType.length > 0) { + if (isSet) { + const knipIssueSets = issuesForType as string[]; + return knipIssueSets.map( + (filePath): CpIssue => ({ + message: ISSUE_TYPE_MESSAGE[issueType](filePath), + severity: severityMap['unknown'], // @TODO rethink + source: { + file: formatGitPath(filePath, gitRoot), + }, + }), + ); + } else { + const knipIssues = issuesForType as KnipIssue[]; + return knipIssues.map((issue): CpIssue => { + const { symbol, filePath, severity = 'unknown' } = issue; + const position = getPosition(issue); + return { + message: ISSUE_TYPE_MESSAGE[issueType](symbol), + severity: severityMap[severity], + source: { + file: filePath, + ...(position ? { position } : {}), + }, + }; + }); + } + } + return []; +} diff --git a/examples/plugins/src/knip/src/reporter/utils.unit.test.ts b/examples/plugins/src/knip/src/reporter/utils.unit.test.ts new file mode 100644 index 000000000..e9d56ea1f --- /dev/null +++ b/examples/plugins/src/knip/src/reporter/utils.unit.test.ts @@ -0,0 +1,216 @@ +import { + type Issue as KnipIssue, + Issues as KnipIssues, +} from 'knip/dist/types/issues'; +import { describe, expect, it } from 'vitest'; +import { auditOutputsSchema } from '@code-pushup/models'; +import { getPosition, knipToCpReport, toIssues } from './utils'; + +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + getGitRoot: vi.fn().mockResolvedValue('User/projects/code-pushup-cli/'), + }; +}); + +describe('getPosition', () => { + it('should return false if no positional information is given', () => { + expect(getPosition({} as KnipIssue)).toBeFalsy(); + }); + + it('should return a object containing file if filePath is given', () => { + expect(getPosition({ col: 3, line: 2 } as KnipIssue)).toEqual({ + startColumn: 3, + startLine: 2, + }); + }); +}); + +describe('toIssues', () => { + it('should return empty issues if a given knip Issue set is empty', async () => { + await expect( + toIssues('files', { + files: new Set([]), + } as KnipIssues), + ).resolves.toStrictEqual([]); + }); + + it('should return empty issues if a given knip issue object is empty', async () => { + await expect( + toIssues('dependencies', { + dependencies: {}, + } as KnipIssues), + ).resolves.toStrictEqual([]); + }); + + it('should return issues with message', async () => { + await expect( + toIssues('files', { + files: new Set(['main.js']), + } as KnipIssues), + ).resolves.toStrictEqual([ + expect.objectContaining({ + message: 'Unused file main.js', + }), + ]); + }); + + it('should return message', async () => { + await expect( + toIssues('files', { + files: new Set(['main.js']), + } as KnipIssues), + ).resolves.toStrictEqual([ + expect.objectContaining({ + message: 'Unused file main.js', + }), + ]); + }); + + it('should return severity', async () => { + await expect( + toIssues('types', { + types: { + 'logging.ts': { + CliUi: { + severity: 'error', + }, + }, + }, + }), + ).resolves.toStrictEqual([ + expect.objectContaining({ + severity: 'error', + }), + ]); + }); + + it('should return source with formatted file path', async () => { + await expect( + toIssues('files', { + files: new Set([ + 'User/projects/code-pushup-cli/packages/utils/main.js', + ]), + } as KnipIssues), + ).resolves.toStrictEqual([ + expect.objectContaining({ + source: { + file: 'packages/utils/main.js', + }, + }), + ]); + }); + + it('should return source position', async () => { + await expect( + toIssues('types', { + types: { + 'logging.ts': { + CliUi: { + type: 'types', + filePath: 'logging.ts', + symbol: 'CliUi', + symbolType: 'type', + pos: 124, + line: 5, + col: 13, + severity: 'error', + }, + }, + }, + }), + ).resolves.toStrictEqual([ + expect.objectContaining({ + source: expect.objectContaining({ + position: { + startColumn: 13, + startLine: 5, + }, + }), + }), + ]); + }); +}); + +describe('knipToCpReport', () => { + it('should return empty audits if no report is flagged positive', async () => { + await expect( + knipToCpReport({ + report: { + files: false, + dependencies: false, + // other options are falsy as undefined + }, + issues: {}, + }), + ).resolves.toStrictEqual([]); + }); + + it('should return only audits flagged in report object', async () => { + await expect( + knipToCpReport({ + report: { + files: true, + dependencies: true, + // other options are falsy as undefined + }, + issues: { + files: new Set(), + dependencies: {}, + }, + }), + ).resolves.toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ slug: 'unused-files' }), + expect.objectContaining({ slug: 'unused-dependencies' }), + ]), + ); + }); + + it('should have value as number of issues', async () => { + await expect( + knipToCpReport({ + report: { files: true }, + issues: { files: new Set(['a.js', 'b.js', 'c.js']) }, + }), + ).resolves.toStrictEqual([expect.objectContaining({ value: 3 })]); + }); + + it('should have no display value', async () => { + await expect( + knipToCpReport({ + report: { files: true }, + issues: { files: new Set(['main.js']) }, + }), + ).resolves.toStrictEqual([ + expect.not.objectContaining({ displayValue: expect.any(String) }), + ]); + }); + + it('should score audits with empty issues with 1', async () => { + await expect( + knipToCpReport({ + report: { files: true }, + issues: { files: new Set() }, + }), + ).resolves.toStrictEqual([expect.objectContaining({ score: 1 })]); + }); + + it('should score audits with issues with 0', async () => { + await expect( + knipToCpReport({ + report: { files: true }, + issues: { files: new Set(['main.js']) }, + }), + ).resolves.toStrictEqual([expect.objectContaining({ score: 0 })]); + }); + + it('should return valid outputs schema', async () => { + const result = await knipToCpReport({ + report: { files: true }, + issues: { files: new Set(['main.js']) }, + }); + expect(() => auditOutputsSchema.parse(result)).not.toThrow(); + }); +}); diff --git a/examples/plugins/src/knip/src/runner/index.ts b/examples/plugins/src/knip/src/runner/index.ts new file mode 100644 index 000000000..1d417da1b --- /dev/null +++ b/examples/plugins/src/knip/src/runner/index.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path'; +import { RunnerConfig } from '@code-pushup/models'; +import { + KNIP_PLUGIN_SLUG, + KNIP_REPORT_NAME, + type KnipAudits, +} from '../constants'; +import { type CustomReporterOptions } from '../reporter'; + +/** + * @description + * Reduced implementation of the knip CLI arguments. + * for a lull list see: https://knip.dev/reference/cli + */ +export type KnipCliOptions = Partial<{ + // https://knip.dev/reference/cli#general + debug: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'config-hints': boolean; + performance: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'isolate-workspaces': boolean; + exitCode: boolean; + // https://knip.dev/reference/cli#configuration + config: string; // file path + tsConfig: string; + workspace: string; // dir path + directory: string; // dir path + gitignore: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'include-entry-exports': string; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'include-libs': string; + // https://knip.dev/reference/cli#modes + production: boolean; + strict: boolean; + // https://knip.dev/reference/cli#filter + exclude: KnipAudits[]; + include: KnipAudits[]; + dependencies: string[]; + exports: string[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + 'experimental-tags': string[]; + tags: string[]; +}>; +export type RunnerOptions = KnipCliOptions & CustomReporterOptions; + +export function createRunnerConfig(options: RunnerOptions = {}): RunnerConfig { + const { + outputFile = join(KNIP_PLUGIN_SLUG, KNIP_REPORT_NAME), + rawOutputFile, + } = options; + return { + command: 'npx', + args: [ + 'knip', + // off as we want to CI to pass + '--no-exit-code', + // off by default to guarantee execution without interference + '--no-progress', + // code-pushup reporter is used statically + // @TODO replace with correct path after release (@code-pushup/knip-plugin/src/reporter/index.js) + '--reporter=./dist/examples/plugins/knip/src/reporter/index.js', + // code-pushup reporter options are passed as string. See + `--reporter-options='${JSON.stringify({ + outputFile, + rawOutputFile, + } satisfies CustomReporterOptions)}'`, + ], + outputFile, + }; +} diff --git a/examples/plugins/src/knip/src/runner/index.unit-test.ts b/examples/plugins/src/knip/src/runner/index.unit-test.ts new file mode 100644 index 000000000..904a90177 --- /dev/null +++ b/examples/plugins/src/knip/src/runner/index.unit-test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { runnerConfigSchema } from '@code-pushup/models'; +import { createRunnerConfig } from './index'; + +describe('runnerConfig', () => { + it('should return correct runner config object', () => { + expect(() => runnerConfigSchema.parse(createRunnerConfig())).not.toThrow(); + }); +}); diff --git a/examples/plugins/src/knip/src/utils.ts b/examples/plugins/src/knip/src/utils.ts new file mode 100644 index 000000000..3f3e4121a --- /dev/null +++ b/examples/plugins/src/knip/src/utils.ts @@ -0,0 +1,40 @@ +import { CategoryRef } from '@code-pushup/models'; +import { + KNIP_PLUGIN_SLUG, + type KnipAudits, + type KnipGroups, +} from './constants'; + +export function knipCategoryAuditRef( + slug: KnipAudits, + weight = 1, +): CategoryRef { + return knipCategoryRef(slug, weight, 'audit'); +} + +export function knipCategoryGroupRef(slug: KnipGroups, weight = 1) { + return knipCategoryRef(slug, weight, 'group'); +} + +function knipCategoryRef( + slug: KnipAudits, + weight: number, + type: 'audit', +): CategoryRef; +function knipCategoryRef( + slug: KnipGroups, + weight: number, + type: 'group', +): CategoryRef; +function knipCategoryRef( + slug: KnipAudits | KnipGroups, + weight: number, + type: CategoryRef['type'], +): CategoryRef { + return { + plugin: KNIP_PLUGIN_SLUG, + slug, + type, + weight, + }; +} diff --git a/examples/plugins/src/knip/src/utils.unit-test.ts b/examples/plugins/src/knip/src/utils.unit-test.ts new file mode 100644 index 000000000..d643f278c --- /dev/null +++ b/examples/plugins/src/knip/src/utils.unit-test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { categoryRefSchema, coreConfigSchema } from '@code-pushup/models'; +import { + KNIP_AUDITS, + KNIP_PLUGIN_SLUG, + KnipAudits, + KnipGroups, +} from './constants'; +import { knipPlugin } from './knip.plugin'; +import { knipCategoryAuditRef, knipCategoryGroupRef } from './utils'; + +describe('knipCategoryAuditRef', () => { + it('should return correct audit category reference object and set weight to 1 by default', () => { + const categoryRef = categoryRefSchema.parse(knipCategoryAuditRef('files')); + expect(categoryRef.slug).toBe('files'); + expect(categoryRef.type).toBe('audit'); + expect(categoryRef.plugin).toBe(KNIP_PLUGIN_SLUG); + expect(categoryRef.weight).toBe(1); + }); + + it('should return correct audit category reference object and with weight 0', () => { + const categoryRef = categoryRefSchema.parse( + knipCategoryAuditRef('files', 0), + ); + expect(categoryRef.slug).toBe('files'); + expect(categoryRef.type).toBe('audit'); + expect(categoryRef.plugin).toBe(KNIP_PLUGIN_SLUG); + expect(categoryRef.weight).toBe(0); + }); +}); + +describe('knipCategoryGroupRef', () => { + it('should return correct group category reference object and set weight to 1 by default', () => { + const categoryRef = categoryRefSchema.parse(knipCategoryGroupRef('files')); + expect(categoryRef.slug).toBe('files'); + expect(categoryRef.type).toBe('audit'); + expect(categoryRef.plugin).toBe(KNIP_PLUGIN_SLUG); + expect(categoryRef.weight).toBe(1); + }); + + it('should return correct group category reference object and with weight 0', () => { + const categoryRef = categoryRefSchema.parse( + knipCategoryGroupRef('files', 0), + ); + expect(categoryRef.slug).toBe('files'); + expect(categoryRef.type).toBe('audit'); + expect(categoryRef.plugin).toBe(KNIP_PLUGIN_SLUG); + expect(categoryRef.weight).toBe(0); + }); +}); + +// test if audits and categorie refs are in sync +describe('knipCategoryGroupRef-within-config', () => { + it.each(['all', 'files', 'dependencies', 'exports'])( + 'should be a valid ref within the config for ref %s', + groupRef => { + const config = coreConfigSchema.parse({ + plugins: [knipPlugin()], + categories: [ + { + slug: 'category-1', + title: 'category 1', + refs: [knipCategoryGroupRef(groupRef)], + }, + ], + }); + expect(config.categories?.[0]?.refs[0]?.slug).toEqual(groupRef); + expect(config.categories?.[0]?.refs[0]?.type).toEqual('group'); + expect(config.categories?.[0]?.refs[0]?.plugin).toEqual(KNIP_PLUGIN_SLUG); + expect(config.categories?.[0]?.refs[0]?.weight).toEqual(1); + }, + ); +}); + +describe('knipCategoryAuditRef-within-config', () => { + it.each(KNIP_AUDITS.map(({ slug }) => slug))( + 'should be a valid ref within the config for ref %s', + auditRef => { + const config = coreConfigSchema.parse({ + plugins: [knipPlugin()], + categories: [ + { + slug: 'category-1', + title: 'category 1', + refs: [knipCategoryAuditRef(auditRef)], + }, + ], + }); + expect(config.categories?.[0]?.refs[0]?.slug).toEqual(auditRef); + expect(config.categories?.[0]?.refs[0]?.type).toEqual('audit'); + expect(config.categories?.[0]?.refs[0]?.plugin).toEqual(KNIP_PLUGIN_SLUG); + expect(config.categories?.[0]?.refs[0]?.weight).toEqual(1); + }, + ); +}); diff --git a/examples/plugins/vite.config.unit.ts b/examples/plugins/vite.config.unit.ts index 2260796b7..bc7a044bf 100644 --- a/examples/plugins/vite.config.unit.ts +++ b/examples/plugins/vite.config.unit.ts @@ -24,6 +24,7 @@ export default defineConfig({ setupFiles: [ '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/git.mock.ts', + '../../testing/test-setup/src/lib/cliui.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', ], diff --git a/examples/react-todos-app/.gitignore b/examples/react-todos-app/.gitignore new file mode 100644 index 000000000..631f6626c --- /dev/null +++ b/examples/react-todos-app/.gitignore @@ -0,0 +1,10 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +node_modules + +# misc +/coverage + +# generated Code PushUp reports +/.code-pushup diff --git a/examples/react-todos-app/knip.config.ts b/examples/react-todos-app/knip.config.ts new file mode 100644 index 000000000..6b2379c05 --- /dev/null +++ b/examples/react-todos-app/knip.config.ts @@ -0,0 +1,3 @@ +export default { + entry: ['**/code-pushup.config.{ts,js,mjs}'], +}; diff --git a/examples/react-todos-app/package-lock.json b/examples/react-todos-app/package-lock.json new file mode 100644 index 000000000..2038807c5 --- /dev/null +++ b/examples/react-todos-app/package-lock.json @@ -0,0 +1,1178 @@ +{ + "name": "todo-app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "todo-app", + "dependencies": { + "react": "^16.12.0", + "semver": "5.7.1" + }, + "devDependencies": { + "vite": "~4.5.0", + "vitest": "0.34.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@types/chai": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz", + "integrity": "sha512-Wj71sXE4Q4AkGdG9Tvq1u/fquNz9EdG4LIJMwVVII7ashjD/8cf8fyIfJAjRr6YcsXnSE8cOGQPq1gqeR8z+3w==", + "dev": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", + "integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, + "node_modules/@types/node": { + "version": "20.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.2.tgz", + "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@vitest/expect": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.0.tgz", + "integrity": "sha512-d1ZU0XomWFAFyYIc6uNuY0N8NJIWESyO/6ZmwLvlHZw0GevH4AEEpq178KjXIvSCrbHN0GnzYzitd0yjfy7+Ow==", + "dev": true, + "dependencies": { + "@vitest/spy": "0.34.0", + "@vitest/utils": "0.34.0", + "chai": "^4.3.7" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.0.tgz", + "integrity": "sha512-xaqM+oArJothtYXzy/dwu/iHe93Khq5QkvnYbzTxiLA0enD2peft1cask3yE6cJpwMkr7C2D1uMJwnTt4mquDw==", + "dev": true, + "dependencies": { + "@vitest/utils": "0.34.0", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.0.tgz", + "integrity": "sha512-eGN5XBZHYOghxCOQbf8dcn6/3g7IW77GOOOC/mNFYwRXsPeoQgcgWnhj+6wgJ04pVv25wpxWL9jUkzaQ7LoFtg==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.0.tgz", + "integrity": "sha512-0SZaWrQvL9ZiF/uJvyWSvsKjfuMvD1M6dE5BbE4Dmt8Vh3k4htwCV8g3ce8YOYmJSxkbh6TNOpippD6NVsxW6w==", + "dev": true, + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.0.tgz", + "integrity": "sha512-IktrDLhBKf3dEUUxH+lcHiPnaw952+GdGvoxg99liMscgP6IePf6LuMY7B9dEIHkFunB1R8VMR/wmI/4UGg1aw==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.8", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", + "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mlly": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", + "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.0.3", + "ufo": "^1.3.2" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/pkg-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", + "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "dev": true, + "dependencies": { + "jsonc-parser": "^3.2.0", + "mlly": "^1.2.0", + "pathe": "^1.1.0" + } + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/rollup": { + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", + "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", + "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ufo": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/vite": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.0.tgz", + "integrity": "sha512-rGZMvpb052rjUwJA/a17xMfOibzNF7byMdRSTcN2Lw8uxX08s5EfjWW5mBkm3MSFTPctMSVtT2yC+8ShrZbT5g==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.4.0", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.0.tgz", + "integrity": "sha512-8Pnc1fVt1P6uBncdUZ++hgiJGgxIRKuz4bmS/PQziaEcUj0D1g9cGiR1MbLrcsvFTC6fgrqDhYoTAdBG356WMA==", + "dev": true, + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.34.0", + "@vitest/runner": "0.34.0", + "@vitest/snapshot": "0.34.0", + "@vitest/spy": "0.34.0", + "@vitest/utils": "0.34.0", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.7", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.7.0", + "vite": "^3.0.0 || ^4.0.0", + "vite-node": "0.34.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/knip.config.ts b/knip.config.ts index aa3ca0e08..c7293b1fc 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -52,6 +52,8 @@ const withNxStandards = (): KnipConfigPlugin => () => { }, exclude: ['duplicates'], entry: [ + '**/src/bin.ts', + '**/perf/**/index.ts', // unknown why this is needed, it should be picked up by knip from the vitest setup files 'testing/test-utils/src/index.ts', 'testing/test-utils/src/lib/fixtures/configs/*.ts', @@ -66,9 +68,12 @@ const withNxStandards = (): KnipConfigPlugin => () => { 'examples/plugins/src/package-json/src/index.ts', // missing knip plugin for now, so this is in the root entry 'packages/models/zod2md.config.ts', - 'code-pushup.config.ts', + // missing knip plugin for now, so this is in the root entry + '**/code-pushup.config.{ts,js,mjs}', 'esbuild.config.js', 'tools/**/*.{js,mjs,ts,cjs,mts,cts}', + // dep from a test for not existing depts + '@example/core', ], ignoreDependencies: [ 'prettier', diff --git a/packages/cli/README.md b/packages/cli/README.md index 89574ac4f..c2e526e8d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -171,6 +171,7 @@ Each example is fully tested to demonstrate best practices for plugin testing as - 📏 [File Size](../../examples/plugins/src/file-size) - example of basic runner executor - 📦 [Package Json](../../examples/plugins/src/package-json) - example of audits and groups - 🔥 [Lighthouse](../../examples/plugins/src/lighthouse) (official implementation [here](../../../../packages/plugin-lighthouse)) - example of a basic command executor +- ✂️ [Knip](../../examples/plugins/src/knip) - package and code usage report ## CLI commands and options diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index eceb671e1..e794875a5 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,3 +1,4 @@ +export { filePathSchema } from './lib/implementation/schemas'; export { Audit, auditSchema } from './lib/audit'; export { AuditDetails, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3613763c3..a8c509993 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -29,6 +29,8 @@ export { formatBytes, formatDuration, pluralize, + singular, + capital, pluralizeToken, slugify, truncateDescription, diff --git a/packages/utils/src/lib/formatting.ts b/packages/utils/src/lib/formatting.ts index 1d88f77aa..64fb32ed6 100644 --- a/packages/utils/src/lib/formatting.ts +++ b/packages/utils/src/lib/formatting.ts @@ -26,6 +26,21 @@ export function pluralize(text: string, amount?: number): string { return `${text}s`; } +export function capital(str: string): string { + return str.at(0)?.toUpperCase() + str.slice(1); +} + +export function singular(typeInPlural: string): string { + if (typeInPlural.endsWith('ies')) { + // eslint-disable-next-line no-magic-numbers + return `${typeInPlural.slice(0, -3)}y`; + } + if (typeInPlural.endsWith('s')) { + return typeInPlural.slice(0, -1); + } + return typeInPlural; +} + export function formatBytes(bytes: number, decimals = 2) { const positiveBytes = Math.max(bytes, 0); diff --git a/packages/utils/src/lib/formatting.unit.test.ts b/packages/utils/src/lib/formatting.unit.test.ts index eb58df929..d852d9e95 100644 --- a/packages/utils/src/lib/formatting.unit.test.ts +++ b/packages/utils/src/lib/formatting.unit.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import { + capital, formatBytes, formatDate, formatDuration, pluralize, pluralizeToken, + singular, slugify, truncateText, } from './formatting'; @@ -28,8 +30,8 @@ describe('pluralize', () => { ['error', 'errors'], ['category', 'categories'], ['status', 'statuses'], - ])('should pluralize "%s" as "%s"', (singular, plural) => { - expect(pluralize(singular)).toBe(plural); + ])('should pluralize "%s" as "%s"', (singularText, plural) => { + expect(pluralize(singularText)).toBe(plural); }); it('should not pluralize if 1 passed in as amount', () => { @@ -41,6 +43,29 @@ describe('pluralize', () => { }); }); +describe('capital', () => { + it('should return the same string just with a capital first letter', () => { + expect(capital('abc')).toBe('Abc'); + }); + + it('should return the same string if already capital', () => { + expect(capital('Abc')).toBe('Abc'); + }); +}); + +describe('singular', () => { + it.each([ + ['files', 'file'], + ['dependencies', 'dependency'], + ['unlisted', 'unlisted'], + ])( + 'should return the singular of a passed plural %s', + (plural, singularText) => { + expect(singular(plural)).toBe(singularText); + }, + ); +}); + describe('formatBytes', () => { it.each([ [0, '0 B'], diff --git a/project.json b/project.json index c65e27d92..1bf32c9af 100644 --- a/project.json +++ b/project.json @@ -10,6 +10,19 @@ "storage": "tmp/local-registry/storage" } }, + "knip": { + "executor": "nx:run-commands", + "options": { + "command": "npx knip --no-exit-code --reporter=./dist/examples/plugins/knip/src/reporter/index.js --reporter-options='{\"outputFile\":\".code-pushup/knip/knip-report.json\"}'", + "forwardAllArgs": true + }, + "dependsOn": [ + { + "projects": ["examples-plugins"], + "target": "build" + } + ] + }, "code-pushup": { "command": "npx dist/packages/cli", "dependsOn": [