├── .gitattributes ├── .markdownlint.json ├── .markdownlintignore ├── test ├── fixtures │ ├── cjs-missing-main │ │ ├── index.cjs │ │ ├── package.json │ │ └── README.md │ ├── cjs-main-directory │ │ ├── lib │ │ │ └── index.cjs │ │ ├── package.json │ │ └── README.md │ ├── cjs │ │ ├── package.json │ │ ├── docs │ │ │ └── rules │ │ │ │ └── no-foo.md │ │ ├── index.cjs │ │ └── README.md │ ├── cjs-config-extends │ │ ├── override-config.cjs │ │ ├── base-base-base-config.cjs │ │ ├── package.json │ │ ├── base-base-config.cjs │ │ ├── docs │ │ │ └── rules │ │ │ │ ├── no-bar.md │ │ │ │ ├── no-baz.md │ │ │ │ ├── no-biz.md │ │ │ │ └── no-foo.md │ │ ├── base-config.cjs │ │ ├── README.md │ │ └── index.cjs │ └── cjs-main-file-does-not-exist │ │ ├── README.md │ │ └── package.json ├── jest.setup.cjs └── lib │ ├── generate │ ├── __snapshots__ │ │ ├── option-postprocess-test.ts.snap │ │ ├── sorting-test.ts.snap │ │ ├── option-check-test.ts.snap │ │ ├── option-rule-doc-notices-test.ts.snap │ │ ├── rule-options-test.ts.snap │ │ ├── option-rule-doc-title-format-test.ts.snap │ │ ├── rule-metadata-test.ts.snap │ │ ├── option-url-configs-test.ts.snap │ │ ├── option-config-format-test.ts.snap │ │ ├── cjs-test.ts.snap │ │ ├── rule-type-test.ts.snap │ │ ├── package-json-test.ts.snap │ │ ├── option-rule-list-columns-test.ts.snap │ │ ├── rule-options-list-test.ts.snap │ │ ├── file-paths-test.ts.snap │ │ ├── rule-description-test.ts.snap │ │ ├── option-config-emoji-test.ts.snap │ │ ├── general-test.ts.snap │ │ ├── option-url-rule-doc-test.ts.snap │ │ ├── eol-test.ts.snap │ │ └── configs-list-test.ts.snap │ ├── option-postprocess-test.ts │ ├── sorting-test.ts │ ├── option-check-test.ts │ ├── cjs-test.ts │ ├── option-rule-doc-section-test.ts │ ├── option-url-configs-test.ts │ ├── option-config-format-test.ts │ ├── rule-metadata-test.ts │ ├── rule-type-test.ts │ ├── option-rule-doc-notices-test.ts │ ├── option-url-rule-doc-test.ts │ └── option-rule-doc-title-format-test.ts │ ├── option-parsers-test.ts │ ├── string-test.ts │ ├── boolean-test.ts │ ├── markdown-test.ts │ └── __snapshots__ │ └── cli-test.ts.snap ├── .prettierrc.json ├── tsconfig.build.json ├── .vscode ├── extensions.json └── settings.json ├── lib ├── mock-plugin.ts ├── index.ts ├── rule-doc-title-format.ts ├── import.ts ├── plugin-prefix.ts ├── config-format.ts ├── comment-markers.ts ├── string.ts ├── boolean.ts ├── rule-type.ts ├── context.ts ├── eol.ts ├── emojis.ts ├── rule-options.ts ├── plugin-config-resolution.ts ├── config-list.ts ├── rule-link.ts ├── plugin-configs.ts ├── option-parsers.ts ├── rule-list-columns.ts ├── markdown.ts ├── rule-options-list.ts └── package-json.ts ├── RELEASE.md ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release-please.yml │ ├── ci.yml │ └── codeql.yml ├── .npmpackagejsonlintrc.json ├── bin └── eslint-doc-generator.ts ├── docs └── examples │ └── eslint-plugin-test │ ├── docs │ └── rules │ │ ├── require-baz.md │ │ ├── prefer-bar.md │ │ └── no-foo.md │ └── README.md ├── tsconfig.json ├── jest.config.ts ├── package.json └── eslint.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false 3 | } 4 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | LICENSE.md 3 | node_modules 4 | -------------------------------------------------------------------------------- /test/fixtures/cjs-missing-main/index.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { rules: {} }; 2 | -------------------------------------------------------------------------------- /test/fixtures/cjs-main-directory/lib/index.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { rules: {} }; 2 | -------------------------------------------------------------------------------- /test/fixtures/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-test", 3 | "type": "commonjs" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/override-config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { rules: { 'test/no-biz': 'error' } }; 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/base-base-base-config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { rules: { 'test/no-bar': 'error' } }; 2 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-test", 3 | "type": "commonjs" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/cjs-missing-main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-test", 3 | "type": "commonjs" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/cjs/docs/rules/no-foo.md: -------------------------------------------------------------------------------- 1 | # Disallow foo (`test/no-foo`) 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/base-base-config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: require.resolve('./base-base-base-config.cjs') }; 2 | -------------------------------------------------------------------------------- /test/fixtures/cjs-main-file-does-not-exist/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "test/**/*.ts", 5 | "jest.config.ts" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/cjs-main-directory/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-test", 3 | "type": "commonjs", 4 | "main": "lib/index.cjs" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "Orta.vscode-jest" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/cjs-main-file-does-not-exist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-test", 3 | "type": "commonjs", 4 | "main": "index.js" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/cjs-main-directory/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-test 2 | 3 | 4 | 5 | | Name | 6 | | :--- | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/cjs-missing-main/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-test 2 | 3 | 4 | 5 | | Name | 6 | | :--- | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/cjs/index.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'no-foo': { 4 | meta: { docs: { description: 'disallow foo.' } }, 5 | create() {}, 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "[typescript]": { 4 | "editor.formatOnPaste": true, 5 | "editor.formatOnSave": true, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/docs/rules/no-bar.md: -------------------------------------------------------------------------------- 1 | # Description of no-bar (`test/no-bar`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/docs/rules/no-baz.md: -------------------------------------------------------------------------------- 1 | # Description of no-baz (`test/no-baz`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/docs/rules/no-biz.md: -------------------------------------------------------------------------------- 1 | # Description of no-biz (`test/no-biz`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/docs/rules/no-foo.md: -------------------------------------------------------------------------------- 1 | # Description of no-foo (`test/no-foo`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | -------------------------------------------------------------------------------- /lib/mock-plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from './types.js'; 2 | 3 | export const mockPlugin: Plugin = { 4 | rules: {}, 5 | configs: { 6 | recommended: { rules: {} }, 7 | all: { rules: {} }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | [release-please](https://github.com/googleapis/release-please) will automatically open up PRs to conduct releases based on [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 4 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | // Exported as part of this package's Node API. 2 | // Type exports controlled by `types` in package.json, non-type exports controlled by `exports` in package.json. 3 | 4 | export type { GenerateOptions } from './types.js'; 5 | -------------------------------------------------------------------------------- /lib/rule-doc-title-format.ts: -------------------------------------------------------------------------------- 1 | export const RULE_DOC_TITLE_FORMATS = [ 2 | 'desc', 3 | 'desc-parens-name', 4 | 'desc-parens-prefix-name', 5 | 'name', 6 | 'prefix-name', 7 | ] as const; 8 | 9 | export type RuleDocTitleFormat = (typeof RULE_DOC_TITLE_FORMATS)[number]; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs/ 3 | *.log 4 | npm-debug.log* 5 | 6 | # Dependency directories 7 | node_modules/ 8 | 9 | # Test output: 10 | .nyc_output 11 | coverage/ 12 | 13 | # ESLint 14 | .eslintcache 15 | 16 | # TypeScript output 17 | dist/ 18 | 19 | # Mac 20 | .DS_Store 21 | 22 | # Agents 23 | .claude/ 24 | -------------------------------------------------------------------------------- /test/fixtures/cjs/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-test 2 | 3 | 4 | 5 | | Name | Description | 6 | | :----------------------------- | :------------ | 7 | | [no-foo](docs/rules/no-foo.md) | disallow foo. | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/import.ts: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'node:url'; 2 | 3 | /** 4 | * Ensure that we import absolute paths correctly in Windows. 5 | * https://github.com/nodejs/node/issues/31710 6 | */ 7 | export function importAbs(path: string) { 8 | const urlHref = pathToFileURL(path).href; 9 | return import(urlHref); 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/base-config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('./base-base-config.cjs')], 3 | rules: { 'test/no-foo': 'error' }, 4 | overrides: [ 5 | { 6 | extends: [require.resolve('./override-config.cjs')], 7 | files: ['*.js'], 8 | rules: { 'test/no-baz': 'error' }, 9 | }, 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: 'npm' 6 | directory: '/' 7 | schedule: 8 | interval: 'weekly' 9 | 10 | - package-ecosystem: 'github-actions' 11 | directory: '/' 12 | schedule: 13 | interval: 'weekly' 14 | -------------------------------------------------------------------------------- /lib/plugin-prefix.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Construct the plugin prefix out of the plugin's name. 3 | */ 4 | export function getPluginPrefix(name: string): string { 5 | return name.endsWith('/eslint-plugin') 6 | ? name.split('/')[0] // Scoped plugin name like @my-scope/eslint-plugin. 7 | : name.replace('eslint-plugin-', ''); // Unscoped name like eslint-plugin-foo or scoped name like @my-scope/eslint-plugin-foo. 8 | } 9 | -------------------------------------------------------------------------------- /test/jest.setup.cjs: -------------------------------------------------------------------------------- 1 | const os = require('node:os'); // eslint-disable-line @typescript-eslint/no-require-imports, no-undef 2 | const sinon = require('sinon'); // eslint-disable-line @typescript-eslint/no-require-imports, no-undef 3 | 4 | // eslint-disable-next-line no-undef 5 | module.exports = function () { 6 | sinon.stub(os, 'EOL').value('\n'); // Stub os.EOL to always be '\n' for testing/snapshot purposes. 7 | }; 8 | -------------------------------------------------------------------------------- /.npmpackagejsonlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-duplicate-properties": "error", 4 | "no-repeated-dependencies": "error", 5 | "prefer-alphabetical-bundledDependencies": "error", 6 | "prefer-alphabetical-dependencies": "error", 7 | "prefer-alphabetical-devDependencies": "error", 8 | "prefer-alphabetical-optionalDependencies": "error", 9 | "prefer-alphabetical-scripts": "error", 10 | "prefer-caret-version-dependencies": "error", 11 | "prefer-caret-version-devDependencies": "error", 12 | "prefer-scripts": ["error", ["lint", "test"]] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bin/eslint-doc-generator.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint n/hashbang:"off" -- shebang needed so compiled code gets interpreted as JS */ 3 | // 4 | // rule was renamed in https://github.com/eslint-community/eslint-plugin-n/releases/tag/v17.0.0 5 | // from n/shebang to n/hashbang 6 | 7 | import { run } from '../lib/cli.js'; 8 | import { generate } from '../lib/generator.js'; 9 | 10 | try { 11 | await run(process.argv, (path, options) => generate(path, options)); 12 | } catch (error: unknown) { 13 | if (error instanceof Error) { 14 | console.error(error.message); 15 | } 16 | process.exitCode = 1; 17 | } 18 | -------------------------------------------------------------------------------- /docs/examples/eslint-plugin-test/docs/rules/require-baz.md: -------------------------------------------------------------------------------- 1 | # Require using baz (`test/require-baz`) 2 | 3 | ❌ This rule is deprecated. It was replaced by [`test/prefer-bar`](prefer-bar.md). 4 | 5 | 🚫 This rule is _disabled_ in the ⌨️ `typescript` config. 6 | 7 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 8 | 9 | 📏 This rule focuses on code formatting. 10 | 11 | 12 | 13 | ## Rule details 14 | 15 | Description of the rule would normally go here. 16 | 17 | ## Examples 18 | 19 | Examples would normally go here. 20 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-postprocess-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (postprocess option) basic calls the postprocessor 1`] = ` 4 | "## Rules 5 | 6 | 7 | | Name | 8 | | :----------------------------- | 9 | | [no-foo](docs/rules/no-foo.md) | 10 | 11 | 12 | 13 | 14 | Located at README.md" 15 | `; 16 | 17 | exports[`generate (postprocess option) basic calls the postprocessor 2`] = ` 18 | "# test/no-foo 19 | 20 | 21 | 22 | 23 | Located at docs/rules/no-foo.md" 24 | `; 25 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-test 2 | 3 | ## Rules 4 | 5 | 6 | 7 | 💼 Configurations enabled in.\ 8 | ✅ Set in the `recommended` configuration. 9 | 10 | | Name | Description | 💼 | 11 | | :----------------------------- | :--------------------- | :- | 12 | | [no-bar](docs/rules/no-bar.md) | Description of no-bar. | ✅ | 13 | | [no-baz](docs/rules/no-baz.md) | Description of no-baz. | ✅ | 14 | | [no-biz](docs/rules/no-biz.md) | Description of no-biz. | ✅ | 15 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | ✅ | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/examples/eslint-plugin-test/docs/rules/prefer-bar.md: -------------------------------------------------------------------------------- 1 | # Enforce using bar (`test/prefer-bar`) 2 | 3 | 💼⚠️ This rule is enabled in the ✅ `recommended` config. This rule _warns_ in the 🎨 `stylistic` config. 4 | 5 | 💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 6 | 7 | 💭 This rule requires [type information](https://typescript-eslint.io/linting/typed-linting). 8 | 9 | 📖 This rule identifies potential improvements. 10 | 11 | 12 | 13 | ## Rule details 14 | 15 | Description of the rule would normally go here. 16 | 17 | ## Examples 18 | 19 | Examples would normally go here. 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "strict": true, 6 | "baseUrl": ".", 7 | "declaration": true, 8 | "experimentalDecorators": true, 9 | "lib": [ 10 | "ES2023" 11 | ], 12 | "module": "ES2022", 13 | "moduleResolution": "node", 14 | "noEmitOnError": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "outDir": "dist", 20 | "target": "es2023", 21 | "types": [ 22 | "node", 23 | "jest" 24 | ], 25 | }, 26 | "include": [ 27 | "bin/**/*", 28 | "lib/**/*", 29 | "test/**/*", 30 | "jest.config.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /test/fixtures/cjs-config-extends/index.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'no-foo': { 4 | meta: { docs: { description: 'Description of no-foo.' } }, 5 | create() {}, 6 | }, 7 | 'no-bar': { 8 | meta: { docs: { description: 'Description of no-bar.' } }, 9 | create() {}, 10 | }, 11 | 'no-baz': { 12 | meta: { docs: { description: 'Description of no-baz.' } }, 13 | create() {}, 14 | }, 15 | 'no-biz': { 16 | meta: { docs: { description: 'Description of no-biz.' } }, 17 | create() {}, 18 | }, 19 | }, 20 | configs: { 21 | recommended: { 22 | extends: [ 23 | require.resolve('./base-config.cjs'), 24 | // Should ignore these since they're external: 25 | 'eslint:recommended', 26 | 'plugin:some-plugin/recommended', 27 | 'prettier', 28 | ], 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /lib/config-format.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context.js'; 2 | 3 | export const CONFIG_FORMATS = [ 4 | 'name', 5 | 'plugin-colon-prefix-name', 6 | 'prefix-name', 7 | ] as const; 8 | 9 | export type ConfigFormat = (typeof CONFIG_FORMATS)[number]; 10 | 11 | export function configNameToDisplay(context: Context, configName: string) { 12 | const { options, pluginPrefix } = context; 13 | const { configFormat } = options; 14 | 15 | switch (configFormat) { 16 | case 'name': { 17 | return configName; 18 | } 19 | case 'plugin-colon-prefix-name': { 20 | return `plugin:${pluginPrefix}/${configName}`; // Exact format used in an ESLint config file under "extends". 21 | } 22 | case 'prefix-name': { 23 | return `${pluginPrefix}/${configName}`; 24 | } 25 | /* istanbul ignore next -- this shouldn't happen */ 26 | default: { 27 | throw new Error(`Unhandled config format: ${String(configFormat)}`); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | issues: write 10 | id-token: write 11 | 12 | name: release-please 13 | 14 | jobs: 15 | release-please: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: googleapis/release-please-action@v4 19 | id: release 20 | with: 21 | release-type: node 22 | - uses: actions/checkout@v6 23 | if: ${{ steps.release.outputs.release_created }} 24 | - uses: actions/setup-node@v6 25 | with: 26 | node-version: lts/* 27 | registry-url: https://registry.npmjs.org 28 | if: ${{ steps.release.outputs.release_created }} 29 | - run: | 30 | npm install --force 31 | npm publish --provenance 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | if: ${{ steps.release.outputs.release_created }} 35 | -------------------------------------------------------------------------------- /lib/comment-markers.ts: -------------------------------------------------------------------------------- 1 | // Markers so that the rules table list can be automatically updated. 2 | export const BEGIN_RULE_LIST_MARKER = 3 | ''; 4 | export const END_RULE_LIST_MARKER = ''; 5 | 6 | // Marker so that rule doc header (title/notices) can be automatically updated. 7 | export const END_RULE_HEADER_MARKER = ''; 8 | 9 | // Markers so that the configs table list can be automatically updated. 10 | export const BEGIN_CONFIG_LIST_MARKER = 11 | ''; 12 | export const END_CONFIG_LIST_MARKER = 13 | ''; 14 | 15 | // Markers so that the rule options table list can be automatically updated. 16 | export const BEGIN_RULE_OPTIONS_LIST_MARKER = 17 | ''; 18 | export const END_RULE_OPTIONS_LIST_MARKER = 19 | ''; 20 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 3 | import { createDefaultEsmPreset } from 'ts-jest'; 4 | 5 | const defaultEsmPreset = createDefaultEsmPreset(); 6 | 7 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 8 | const config: Config = { 9 | testEnvironment: 'node', 10 | testMatch: ['/test/**/*-test.ts'], 11 | setupFiles: ['/test/jest.setup.cjs'], 12 | ...defaultEsmPreset, 13 | moduleNameMapper: { 14 | '^(\\.{1,2}/.*)\\.js$': '$1', 15 | '#(.*)': '/node_modules/$1', 16 | }, 17 | coveragePathIgnorePatterns: [ 18 | '/node_modules/', 19 | '/test/', 20 | // these files are created by the test suite in the in-memory fs, 21 | // which test has access to and so counts in coverage by default 22 | '/index.js', 23 | '/index-foo.js', 24 | ], 25 | coverageThreshold: { 26 | global: { 27 | branches: 100, 28 | functions: 100, 29 | lines: 100, 30 | statements: 100, 31 | }, 32 | }, 33 | }; 34 | 35 | export default config; 36 | -------------------------------------------------------------------------------- /lib/string.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context.js'; 2 | 3 | export function toSentenceCase(str: string) { 4 | return str.replace(/^\w/u, function (txt) { 5 | return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(); 6 | }); 7 | } 8 | 9 | export function addTrailingPeriod(str: string) { 10 | return str.replace(/\.?$/u, '.'); 11 | } 12 | 13 | export function removeTrailingPeriod(str: string) { 14 | return str.replace(/\.$/u, ''); 15 | } 16 | 17 | /** 18 | * Example: FOO => Foo, foo => Foo 19 | */ 20 | export function capitalizeOnlyFirstLetter(str: string) { 21 | return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); 22 | } 23 | 24 | function sanitizeMarkdownTableCell(context: Context, text: string): string { 25 | const { endOfLine } = context; 26 | 27 | return text 28 | .replaceAll('|', String.raw`\|`) 29 | .replaceAll(new RegExp(endOfLine, 'gu'), '
'); 30 | } 31 | 32 | export function sanitizeMarkdownTable( 33 | context: Context, 34 | text: readonly (readonly string[])[], 35 | ): readonly (readonly string[])[] { 36 | return text.map((row) => 37 | row.map((col) => sanitizeMarkdownTableCell(context, col)), 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | permissions: 13 | contents: read # to fetch code (actions/checkout) 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ${{ matrix.os }}-latest 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: [ ubuntu, windows ] 24 | node-version: [18.x, 20.x, 22.x, 23.x, 24.x] 25 | 26 | steps: 27 | - uses: actions/checkout@v6 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Use Node.js version ${{ matrix.node-version }} 32 | uses: actions/setup-node@v6 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | cache: 'npm' 36 | 37 | - name: Install dependencies 38 | run: npm ci 39 | 40 | - name: Run linters 41 | run: npm run lint 42 | 43 | - name: Run tests 44 | run: npm test 45 | -------------------------------------------------------------------------------- /lib/boolean.ts: -------------------------------------------------------------------------------- 1 | // Originally from: https://www.npmjs.com/package/boolean 2 | 3 | export function boolean(value: unknown): boolean { 4 | switch (typeof value) { 5 | case 'string': { 6 | return ['true', 't', 'yes', 'y', 'on', '1'].includes( 7 | value.trim().toLowerCase(), 8 | ); 9 | } 10 | 11 | case 'number': { 12 | return value.valueOf() === 1; 13 | } 14 | 15 | case 'boolean': { 16 | return value.valueOf(); 17 | } 18 | 19 | default: { 20 | return false; 21 | } 22 | } 23 | } 24 | 25 | export function isBooleanable(value: unknown): boolean { 26 | switch (typeof value) { 27 | case 'string': { 28 | return [ 29 | 'true', 30 | 't', 31 | 'yes', 32 | 'y', 33 | 'on', 34 | '1', 35 | 'false', 36 | 'f', 37 | 'no', 38 | 'n', 39 | 'off', 40 | '0', 41 | ].includes(value.trim().toLowerCase()); 42 | } 43 | 44 | case 'number': { 45 | return [0, 1].includes(Number(value).valueOf()); 46 | } 47 | 48 | case 'boolean': { 49 | return true; 50 | } 51 | 52 | default: { 53 | return false; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/lib/option-parsers-test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseConfigEmojiOptions, 3 | parseRuleListColumnsOption, 4 | parseRuleDocNoticesOption, 5 | } from '../../lib/option-parsers.js'; 6 | import { mockPlugin } from '../../lib/mock-plugin.js'; 7 | 8 | describe('option-parsers', function () { 9 | describe('parseConfigEmojiOptions', function () { 10 | it('handles undefined configEmoji and returns default emojis', function () { 11 | const result = parseConfigEmojiOptions(mockPlugin, undefined); 12 | 13 | // Should return default emojis for recognized configs. 14 | expect(result).toContainEqual({ config: 'recommended', emoji: '✅' }); 15 | expect(result).toContainEqual({ config: 'all', emoji: '🌐' }); 16 | }); 17 | }); 18 | 19 | describe('parseRuleListColumnsOption', function () { 20 | it('handles undefined input', function () { 21 | const result = parseRuleListColumnsOption(undefined); 22 | expect(result.length).toBeGreaterThan(0); 23 | }); 24 | }); 25 | 26 | describe('parseRuleDocNoticesOption', function () { 27 | it('handles undefined input', function () { 28 | const result = parseRuleDocNoticesOption(undefined); 29 | expect(result.length).toBeGreaterThan(0); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/lib/string-test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addTrailingPeriod, 3 | removeTrailingPeriod, 4 | toSentenceCase, 5 | } from '../../lib/string.js'; 6 | 7 | describe('strings', function () { 8 | describe('#addTrailingPeriod', function () { 9 | it('handles when already has period', function () { 10 | expect(addTrailingPeriod('foo.')).toStrictEqual('foo.'); 11 | }); 12 | 13 | it('handles when does not have period', function () { 14 | expect(addTrailingPeriod('foo')).toStrictEqual('foo.'); 15 | }); 16 | }); 17 | 18 | describe('#removeTrailingPeriod', function () { 19 | it('handles when already has period', function () { 20 | expect(removeTrailingPeriod('foo.')).toStrictEqual('foo'); 21 | }); 22 | 23 | it('handles when does not have period', function () { 24 | expect(removeTrailingPeriod('foo')).toStrictEqual('foo'); 25 | }); 26 | }); 27 | 28 | describe('#toSentenceCase', function () { 29 | it('handles when lowercase first letter', function () { 30 | expect(toSentenceCase('hello world')).toStrictEqual('Hello world'); 31 | }); 32 | 33 | it('handles when uppercase first letter', function () { 34 | expect(toSentenceCase('Hello World')).toStrictEqual('Hello World'); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/sorting-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (sorting) sorting rules and configs case-insensitive sorts correctly 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💼 Configurations enabled in. 8 | 9 | | Name | 💼 | 10 | | :------------------- | :------------------------------------- | 11 | | [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | 12 | | [B](docs/rules/B.md) | | 13 | | [c](docs/rules/c.md) | | 14 | 15 | 16 | " 17 | `; 18 | 19 | exports[`generate (sorting) sorting rules and configs case-insensitive sorts correctly 2`] = ` 20 | "# test/a 21 | 22 | 💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. 23 | 24 | 25 | " 26 | `; 27 | 28 | exports[`generate (sorting) sorting rules and configs case-insensitive sorts correctly 3`] = ` 29 | "# test/B 30 | 31 | 32 | " 33 | `; 34 | 35 | exports[`generate (sorting) sorting rules and configs case-insensitive sorts correctly 4`] = ` 36 | "# test/c 37 | 38 | 39 | " 40 | `; 41 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-check-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--check) basic prints the issues, exits with failure, and does not write changes 1`] = ` 4 | [ 5 | "- Expected 6 | + Received 7 | 8 | - # Description for no-foo (\`test/no-foo\`) 9 | - 10 | -  11 | - 12 | + # test/no-foo", 13 | ] 14 | `; 15 | 16 | exports[`generate (--check) basic prints the issues, exits with failure, and does not write changes 2`] = ` 17 | [ 18 | "- Expected 19 | + Received 20 | 21 |  ## Rules 22 | -  23 | - 24 | - | Name | Description | 25 | - | :----------------------------- | :---------------------- | 26 | - | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | 27 | - 28 | -  29 | ", 30 | ] 31 | `; 32 | 33 | exports[`generate (--check) basic prints the issues, exits with failure, and does not write changes 3`] = ` 34 | "## Rules 35 | " 36 | `; 37 | 38 | exports[`generate (--check) basic prints the issues, exits with failure, and does not write changes 4`] = `"# test/no-foo"`; 39 | -------------------------------------------------------------------------------- /lib/rule-type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum version of this union type: TSESLint.RuleMetaData<''>['type']; 3 | */ 4 | export enum RULE_TYPE { 5 | 'problem' = 'problem', 6 | 'suggestion' = 'suggestion', 7 | 'layout' = 'layout', 8 | } 9 | 10 | export const RULE_TYPES = ['problem', 'suggestion', 'layout'] as const; 11 | 12 | export const EMOJIS_TYPE: { [key in RULE_TYPE]: string } = { 13 | [RULE_TYPE.problem]: '❗', 14 | [RULE_TYPE.suggestion]: '📖', 15 | [RULE_TYPE.layout]: '📏', 16 | }; 17 | 18 | export const RULE_TYPE_MESSAGES_LEGEND: { [key in RULE_TYPE]: string } = { 19 | [RULE_TYPE.problem]: `${ 20 | EMOJIS_TYPE[RULE_TYPE.problem] 21 | } Identifies problems that could cause errors or unexpected behavior.`, 22 | [RULE_TYPE.suggestion]: `${ 23 | EMOJIS_TYPE[RULE_TYPE.suggestion] 24 | } Identifies potential improvements.`, 25 | [RULE_TYPE.layout]: `${ 26 | EMOJIS_TYPE[RULE_TYPE.layout] 27 | } Focuses on code formatting.`, 28 | }; 29 | 30 | export const RULE_TYPE_MESSAGES_NOTICES: { [key in RULE_TYPE]: string } = { 31 | [RULE_TYPE.problem]: `${ 32 | EMOJIS_TYPE[RULE_TYPE.problem] 33 | } This rule identifies problems that could cause errors or unexpected behavior.`, 34 | [RULE_TYPE.suggestion]: `${ 35 | EMOJIS_TYPE[RULE_TYPE.suggestion] 36 | } This rule identifies potential improvements.`, 37 | [RULE_TYPE.layout]: `${ 38 | EMOJIS_TYPE[RULE_TYPE.layout] 39 | } This rule focuses on code formatting.`, 40 | }; 41 | -------------------------------------------------------------------------------- /lib/context.ts: -------------------------------------------------------------------------------- 1 | import { mockPlugin } from './mock-plugin.js'; 2 | import { getEndOfLine } from './eol.js'; 3 | import { getResolvedOptions, ResolvedGenerateOptions } from './options.js'; 4 | import { getPluginName, loadPlugin } from './package-json.js'; 5 | import { ConfigsToRules, GenerateOptions, Plugin } from './types.js'; 6 | import { getPluginPrefix } from './plugin-prefix.js'; 7 | import { resolveConfigsToRules } from './plugin-config-resolution.js'; 8 | 9 | /** 10 | * Context about the current invocation of the program, like what end-of-line 11 | * character to use. 12 | */ 13 | export interface Context { 14 | configsToRules: ConfigsToRules; 15 | endOfLine: string; 16 | options: ResolvedGenerateOptions; 17 | path: string; 18 | plugin: Plugin; 19 | pluginPrefix: string; 20 | } 21 | 22 | export async function getContext( 23 | path: string, 24 | userOptions?: GenerateOptions, 25 | useMockPlugin = false, 26 | ): Promise { 27 | const endOfLine = await getEndOfLine(); 28 | const plugin = useMockPlugin ? mockPlugin : await loadPlugin(path); 29 | const pluginPrefix = getPluginPrefix( 30 | plugin.meta?.name ?? (await getPluginName(path)), 31 | ); 32 | 33 | const configsToRules = await resolveConfigsToRules(plugin); 34 | const options = getResolvedOptions(plugin, userOptions); 35 | 36 | return { 37 | configsToRules, 38 | endOfLine, 39 | options, 40 | path, 41 | plugin, 42 | pluginPrefix, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-rule-doc-notices-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--rule-doc-notices) basic shows the right rule doc notices 1`] = ` 4 | "## Rules 5 | 6 | 7 | 🔧 Automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\\ 8 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\\ 9 | ❌ Deprecated. 10 | 11 | | Name | Description | 🔧 | 💡 | ❌ | 12 | | :----------------------------- | :---------------------- | :- | :- | :- | 13 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | 🔧 | 💡 | ❌ | 14 | 15 | 16 | " 17 | `; 18 | 19 | exports[`generate (--rule-doc-notices) basic shows the right rule doc notices 2`] = ` 20 | "# Description for no-foo (\`test/no-foo\`) 21 | 22 | 💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 23 | 24 | 🔧 This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 25 | 26 | ❌ This rule is deprecated. 27 | 28 | Description for no-foo. 29 | 30 | ❗ This rule identifies problems that could cause errors or unexpected behavior. 31 | 32 | 33 | " 34 | `; 35 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/rule-options-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (rule options) rule with options, options column/notice enabled displays the column and notice 1`] = ` 4 | "## Rules 5 | 6 | 7 | ⚙️ Has configuration options. 8 | 9 | | Name | ⚙️ | 10 | | :----------------------------- | :- | 11 | | [no-bar](docs/rules/no-bar.md) | ⚙️ | 12 | | [no-baz](docs/rules/no-baz.md) | | 13 | | [no-biz](docs/rules/no-biz.md) | | 14 | | [no-foo](docs/rules/no-foo.md) | ⚙️ | 15 | 16 | 17 | " 18 | `; 19 | 20 | exports[`generate (rule options) rule with options, options column/notice enabled displays the column and notice 2`] = ` 21 | "# test/no-foo 22 | 23 | ⚙️ This rule is configurable. 24 | 25 | 26 | ## Options 27 | " 28 | `; 29 | 30 | exports[`generate (rule options) rule with options, options column/notice enabled displays the column and notice 3`] = ` 31 | "# test/no-bar 32 | 33 | ⚙️ This rule is configurable. 34 | 35 | 36 | ## Options 37 | " 38 | `; 39 | 40 | exports[`generate (rule options) rule with options, options column/notice enabled displays the column and notice 4`] = ` 41 | "# test/no-biz 42 | 43 | 44 | " 45 | `; 46 | 47 | exports[`generate (rule options) rule with options, options column/notice enabled displays the column and notice 5`] = ` 48 | "# test/no-baz 49 | 50 | 51 | " 52 | `; 53 | -------------------------------------------------------------------------------- /test/lib/boolean-test.ts: -------------------------------------------------------------------------------- 1 | import { boolean, isBooleanable } from '../../lib/boolean.js'; 2 | 3 | describe('boolean', function () { 4 | describe('#boolean', function () { 5 | it.each(['true', 't', 'yes', 'y', 'on', '1'])( 6 | 'returns true when string is %s', 7 | function (value) { 8 | expect(boolean(value)).toBe(true); 9 | }, 10 | ); 11 | 12 | it('returns true when number is 1', function () { 13 | expect(boolean(1)).toBe(true); 14 | }); 15 | 16 | it('returns true when boolean is true', function () { 17 | expect(boolean(true)).toBe(true); 18 | }); 19 | 20 | it.each(['foo', 2, undefined])( 21 | 'returns false when value is %p', 22 | function (value) { 23 | expect(boolean(value)).toBe(false); 24 | }, 25 | ); 26 | }); 27 | 28 | describe('#isBooleanable', function () { 29 | it.each([ 30 | 'true', 31 | 't', 32 | 'yes', 33 | 'y', 34 | 'on', 35 | '1', 36 | 'false', 37 | 'f', 38 | 'no', 39 | 'n', 40 | 'off', 41 | '0', 42 | ])('returns true when string is %s', function (value) { 43 | expect(isBooleanable(value)).toBe(true); 44 | }); 45 | 46 | it.each([0, 1])('returns true when number is %i', function (value) { 47 | expect(isBooleanable(value)).toBe(true); 48 | }); 49 | 50 | it.each([true, false])('returns when boolean is %s', function (value) { 51 | expect(isBooleanable(value)).toBe(true); 52 | }); 53 | 54 | it.each(['foo', 2, undefined])( 55 | 'returns false when value is %p', 56 | function (value) { 57 | expect(isBooleanable(value)).toBe(false); 58 | }, 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-rule-doc-title-format-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--rule-doc-title-format) desc uses the right rule doc title format 1`] = ` 4 | "# Description for no-foo 5 | 6 | 7 | " 8 | `; 9 | 10 | exports[`generate (--rule-doc-title-format) desc uses the right rule doc title format 2`] = ` 11 | "# test/no-bar 12 | 13 | 14 | " 15 | `; 16 | 17 | exports[`generate (--rule-doc-title-format) desc-parens-name uses the right rule doc title format 1`] = ` 18 | "# Description for no-foo (\`no-foo\`) 19 | 20 | 21 | " 22 | `; 23 | 24 | exports[`generate (--rule-doc-title-format) desc-parens-name uses the right rule doc title format 2`] = ` 25 | "# no-bar 26 | 27 | 28 | " 29 | `; 30 | 31 | exports[`generate (--rule-doc-title-format) desc-parens-prefix-name uses the right rule doc title format, with fallback when missing description 1`] = ` 32 | "# Description for no-foo (\`test/no-foo\`) 33 | 34 | 35 | " 36 | `; 37 | 38 | exports[`generate (--rule-doc-title-format) desc-parens-prefix-name uses the right rule doc title format, with fallback when missing description 2`] = ` 39 | "# test/no-bar 40 | 41 | 42 | " 43 | `; 44 | 45 | exports[`generate (--rule-doc-title-format) name uses the right rule doc title format 1`] = ` 46 | "# no-foo 47 | 48 | 49 | " 50 | `; 51 | 52 | exports[`generate (--rule-doc-title-format) prefix-name uses the right rule doc title format 1`] = ` 53 | "# test/no-foo 54 | 55 | 56 | " 57 | `; 58 | -------------------------------------------------------------------------------- /docs/examples/eslint-plugin-test/docs/rules/no-foo.md: -------------------------------------------------------------------------------- 1 | # Disallow using foo (`test/no-foo`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | ⚙️ This rule is configurable. 8 | 9 | 💭 This rule requires [type information](https://typescript-eslint.io/linting/typed-linting). 10 | 11 | ❗ This rule identifies problems that could cause errors or unexpected behavior. 12 | 13 | 14 | 15 | ## Rule details 16 | 17 | Description of the rule would normally go here. 18 | 19 | ## Examples 20 | 21 | Examples would normally go here. 22 | 23 | ## Options 24 | 25 | 26 | 27 | | Name | Description | Type | Choices | Default | Required | Deprecated | 28 | | :---- | :---------------------------- | :------ | :---------------- | :------- | :------- | :--------- | 29 | | `bar` | Choose how to use the rule. | String | `always`, `never` | `always` | Yes | | 30 | | `foo` | Enable some kind of behavior. | Boolean | | `false` | | Yes | 31 | 32 | 33 | 34 | For the purpose of this example, below is the `meta.schema` that would generate the above rule options table: 35 | 36 | ```json 37 | [{ 38 | "type": "object", 39 | "properties": { 40 | "foo": { 41 | "type": "boolean", 42 | "description": "Enable some kind of behavior.", 43 | "deprecated": true, 44 | "default": false 45 | }, 46 | "bar": { 47 | "description": "Choose how to use the rule.", 48 | "type": "string", 49 | "enum": ["always", "never"], 50 | "default": "always" 51 | } 52 | }, 53 | "required": ["bar"], 54 | "additionalProperties": false 55 | }] 56 | ``` 57 | -------------------------------------------------------------------------------- /test/lib/generate/option-postprocess-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve, relative } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (postprocess option)', function () { 13 | describe('basic', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': { 26 | meta: {}, 27 | create(context) {} 28 | }, 29 | }, 30 | };`, 31 | 32 | 'README.md': '## Rules\n', 33 | 34 | 'docs/rules/no-foo.md': '', 35 | 36 | // Needed for some of the test infrastructure to work. 37 | node_modules: mockFs.load(PATH_NODE_MODULES), 38 | }); 39 | }); 40 | 41 | afterEach(function () { 42 | mockFs.restore(); 43 | jest.resetModules(); 44 | }); 45 | 46 | it('calls the postprocessor', async function () { 47 | await generate('.', { 48 | postprocess: (content, path) => 49 | [ 50 | content, 51 | '', 52 | `Located at ${relative('.', path).replaceAll('\\', '/')}`, // Always use forward slashes in the path so the snapshot is right even when testing on Windows. 53 | ].join('\n'), 54 | }); 55 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 56 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /docs/examples/eslint-plugin-test/README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-test 2 | 3 | This plugin is for x purpose. 4 | 5 | ## Configs 6 | 7 | 8 | 9 | | | Name | Description | 10 | | :- | :------------ | :----------------------------------------------- | 11 | | ✅ | `recommended` | These rules are recommended for everyone. | 12 | | 🎨 | `stylistic` | These rules are more about code style than bugs. | 13 | | ⌨️ | `typescript` | These are good rules to use with TypeScript. | 14 | 15 | 16 | 17 | ## Rules 18 | 19 | 20 | 21 | 💼 Configurations enabled in.\ 22 | ⚠️ Configurations set to warn in.\ 23 | 🚫 Configurations disabled in.\ 24 | ✅ Set in the `recommended` configuration.\ 25 | 🎨 Set in the `stylistic` configuration.\ 26 | ⌨️ Set in the `typescript` configuration.\ 27 | 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ 28 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\ 29 | ⚙️ Has configuration options.\ 30 | 💭 Requires [type information](https://typescript-eslint.io/linting/typed-linting).\ 31 | 🗂️ The type of rule.\ 32 | ❗ Identifies problems that could cause errors or unexpected behavior.\ 33 | 📖 Identifies potential improvements.\ 34 | 📏 Focuses on code formatting.\ 35 | ❌ Deprecated. 36 | 37 | | Name | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ⚙️ | 💭 | 🗂️ | ❌ | 38 | | :--------------------------------------- | :----------------- | :- | :- | :- | :- | :- | :- | :- | :-- | :- | 39 | | [no-foo](docs/rules/no-foo.md) | disallow using foo | ✅ | | | 🔧 | | ⚙️ | 💭 | ❗ | | 40 | | [prefer-bar](docs/rules/prefer-bar.md) | enforce using bar | ✅ | 🎨 | | | 💡 | | 💭 | 📖 | | 41 | | [require-baz](docs/rules/require-baz.md) | require using baz | | | ⌨️ | 🔧 | | | | 📏 | ❌ | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/lib/markdown-test.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from 'outdent'; 2 | import { findSectionHeader } from '../../lib/markdown.js'; 3 | import { getContext } from '../../lib/context.js'; 4 | 5 | const cwd = process.cwd(); 6 | const context = await getContext(cwd, undefined, true); 7 | 8 | describe('markdown', function () { 9 | describe('#findSectionHeader', function () { 10 | it('handles standard section title', function () { 11 | const title = '## Rules\n'; 12 | expect(findSectionHeader(context, title, 'rules')).toBe(title); 13 | }); 14 | 15 | it('handles section title with leading emoji', function () { 16 | const title = '## 🍟 Rules\n'; 17 | expect(findSectionHeader(context, title, 'rules')).toBe(title); 18 | }); 19 | 20 | it('handles section title with html', function () { 21 | const title = "## Rules\n"; 22 | expect(findSectionHeader(context, title, 'rules')).toBe(title); 23 | }); 24 | 25 | it('handles sentential section title', function () { 26 | const title = '## List of supported rules\n'; 27 | expect(findSectionHeader(context, title, 'rules')).toBe(title); 28 | }); 29 | 30 | it('handles doc with multiple sections', function () { 31 | expect( 32 | findSectionHeader( 33 | context, 34 | outdent` 35 | # eslint-plugin-test 36 | Description. 37 | ## Rules 38 | Rules. 39 | ## Other section 40 | Foo. 41 | `, 42 | 'rules', 43 | ), 44 | ).toBe('## Rules\n'); 45 | }); 46 | 47 | it('handles doc with multiple rules-related sections', function () { 48 | expect( 49 | findSectionHeader( 50 | context, 51 | outdent` 52 | # eslint-plugin-test 53 | Description. 54 | ## Rules with foo 55 | Rules with foo. 56 | ## Rules 57 | Rules. 58 | ## More specific section about rules 59 | Foo. 60 | `, 61 | 'rules', 62 | ), 63 | ).toBe('## Rules\n'); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/rule-metadata-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (rule metadata) deprecated function-style rule generates the documentation 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💼 Configurations enabled in.\\ 8 | ✅ Set in the \`recommended\` configuration. 9 | 10 | | Name | 💼 | 11 | | :----------------------------- | :- | 12 | | [no-foo](docs/rules/no-foo.md) | ✅ | 13 | 14 | 15 | " 16 | `; 17 | 18 | exports[`generate (rule metadata) deprecated function-style rule generates the documentation 2`] = ` 19 | "# test/no-foo 20 | 21 | 22 | " 23 | `; 24 | 25 | exports[`generate (rule metadata) deprecated function-style rule with deprecated/schema properties generates the documentation 1`] = ` 26 | "## Rules 27 | 28 | 29 | ❌ Deprecated.\\ 30 | ⚙️ Has configuration options. 31 | 32 | | Name | ❌ | ⚙️ | 33 | | :----------------------------- | :- | :- | 34 | | [no-foo](docs/rules/no-foo.md) | ❌ | ⚙️ | 35 | 36 | 37 | " 38 | `; 39 | 40 | exports[`generate (rule metadata) deprecated function-style rule with deprecated/schema properties generates the documentation 2`] = ` 41 | "# test/no-foo 42 | 43 | 44 | ## Options 45 | optionToDoSomething" 46 | `; 47 | 48 | exports[`generate (rule metadata) rule with no meta object generates the documentation 1`] = ` 49 | "## Rules 50 | 51 | 52 | 💼 Configurations enabled in.\\ 53 | ✅ Set in the \`recommended\` configuration. 54 | 55 | | Name | 💼 | 56 | | :----------------------------- | :- | 57 | | [no-foo](docs/rules/no-foo.md) | ✅ | 58 | 59 | 60 | " 61 | `; 62 | 63 | exports[`generate (rule metadata) rule with no meta object generates the documentation 2`] = ` 64 | "# test/no-foo 65 | 66 | 💼 This rule is enabled in the ✅ \`recommended\` config. 67 | 68 | 69 | " 70 | `; 71 | -------------------------------------------------------------------------------- /test/lib/generate/sorting-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (sorting)', function () { 13 | describe('sorting rules and configs case-insensitive', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'c': { meta: { docs: {} }, create(context) {} }, 26 | 'a': { meta: { docs: {} }, create(context) {} }, 27 | 'B': { meta: { docs: {} }, create(context) {} }, 28 | }, 29 | configs: { 30 | 'c': { rules: { 'test/a': 'error', } }, 31 | 'a': { rules: { 'test/a': 'error', } }, 32 | 'B': { rules: { 'test/a': 'error', } }, 33 | } 34 | };`, 35 | 36 | 'README.md': '## Rules\n', 37 | 38 | 'docs/rules/a.md': '', 39 | 'docs/rules/B.md': '', 40 | 'docs/rules/c.md': '', 41 | 42 | // Needed for some of the test infrastructure to work. 43 | node_modules: mockFs.load(PATH_NODE_MODULES), 44 | }); 45 | }); 46 | 47 | afterEach(function () { 48 | mockFs.restore(); 49 | jest.resetModules(); 50 | }); 51 | 52 | it('sorts correctly', async function () { 53 | await generate('.'); 54 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 55 | expect(readFileSync('docs/rules/a.md', 'utf8')).toMatchSnapshot(); 56 | expect(readFileSync('docs/rules/B.md', 'utf8')).toMatchSnapshot(); 57 | expect(readFileSync('docs/rules/c.md', 'utf8')).toMatchSnapshot(); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /lib/eol.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from 'node:os'; 2 | import editorconfig from 'editorconfig'; 3 | 4 | export async function getEndOfLine(): Promise<'\n' | '\r\n'> { 5 | return ( 6 | (await getEndOfLineFromEditorConfig()) ?? 7 | (await getEndOfLineFromPrettierConfig()) ?? 8 | getNodeEOL() 9 | ); 10 | } 11 | 12 | async function getEndOfLineFromEditorConfig(): Promise< 13 | '\n' | '\r\n' | undefined 14 | > { 15 | // The passed `markdown.md` argument is used as an example of a Markdown file in the plugin root 16 | // folder in order to check for any specific Markdown configurations. 17 | const editorConfigProps = await editorconfig.parse('markdown.md'); 18 | 19 | if (editorConfigProps.end_of_line === 'lf') { 20 | return '\n'; 21 | } 22 | 23 | if (editorConfigProps.end_of_line === 'crlf') { 24 | return '\r\n'; 25 | } 26 | 27 | return undefined; 28 | } 29 | 30 | async function getEndOfLineFromPrettierConfig(): Promise< 31 | '\n' | '\r\n' | undefined 32 | > { 33 | let prettier: typeof import('prettier') | undefined; 34 | try { 35 | prettier = await import('prettier'); 36 | } catch { 37 | /* istanbul ignore next */ 38 | return undefined; 39 | } 40 | 41 | // The passed `markdown.md` argument is used as an example of a Markdown file in the plugin root 42 | // folder in order to check for any specific Markdown configurations. 43 | const prettierOptions = await prettier.resolveConfig('markdown.md'); 44 | 45 | if (prettierOptions === null) { 46 | return undefined; 47 | } 48 | 49 | if (prettierOptions.endOfLine === 'lf') { 50 | return '\n'; 51 | } 52 | 53 | if (prettierOptions.endOfLine === 'crlf') { 54 | return '\r\n'; 55 | } 56 | 57 | // Prettier defaults to "lf" if it is not explicitly specified in the config file: 58 | // https://prettier.io/docs/options#end-of-line 59 | return '\n'; 60 | } 61 | 62 | /* istanbul ignore next */ 63 | /** `EOL` is typed as `string`, so we perform run-time validation to be safe. */ 64 | function getNodeEOL(): '\n' | '\r\n' { 65 | if (EOL === '\n' || EOL === '\r\n') { 66 | return EOL; 67 | } 68 | 69 | throw new Error( 70 | `Failed to detect the end-of-line constant from the JavaScript runtime: ${EOL}`, 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-url-configs-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--url-configs) basic includes the config link 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💼 [Configurations](https://example.com/configs) enabled in.\\ 8 | ✅ Set in the \`recommended\` [configuration](https://example.com/configs). 9 | 10 | | Name | Description | 💼 | 11 | | :----------------------------- | :---------------------- | :---------------------- | 12 | | [no-bar](docs/rules/no-bar.md) | Description for no-bar. | ![badge-customConfig][] | 13 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ✅ | 14 | 15 | 16 | " 17 | `; 18 | 19 | exports[`generate (--url-configs) basic includes the config link 2`] = ` 20 | "# Description for no-foo (\`test/no-foo\`) 21 | 22 | 💼 This rule is enabled in the ✅ \`recommended\` [config](https://example.com/configs). 23 | 24 | 25 | " 26 | `; 27 | 28 | exports[`generate (--url-configs) basic includes the config link 3`] = ` 29 | "# Description for no-bar (\`test/no-bar\`) 30 | 31 | 💼 This rule is enabled in the \`customConfig\` [config](https://example.com/configs). 32 | 33 | 34 | " 35 | `; 36 | 37 | exports[`generate (--url-configs) with only recommended config includes the config link 1`] = ` 38 | "## Rules 39 | 40 | 41 | 💼 [Configurations](https://example.com/configs) enabled in.\\ 42 | ✅ Set in the \`recommended\` [configuration](https://example.com/configs). 43 | 44 | | Name | Description | 💼 | 45 | | :----------------------------- | :---------------------- | :- | 46 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ✅ | 47 | 48 | 49 | " 50 | `; 51 | 52 | exports[`generate (--url-configs) with only recommended config includes the config link 2`] = ` 53 | "# Description for no-foo (\`test/no-foo\`) 54 | 55 | 💼 This rule is enabled in the ✅ \`recommended\` [config](https://example.com/configs). 56 | 57 | 58 | " 59 | `; 60 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-config-format-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--config-format) name uses the right format 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💼 Configurations enabled in.\\ 8 | ✅ Set in the \`recommended\` configuration. 9 | 10 | | Name | Description | 💼 | 11 | | :----------------------------- | :---------------------- | :- | 12 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ✅ | 13 | 14 | 15 | " 16 | `; 17 | 18 | exports[`generate (--config-format) name uses the right format 2`] = ` 19 | "# Description for no-foo (\`test/no-foo\`) 20 | 21 | 💼 This rule is enabled in the ✅ \`recommended\` config. 22 | 23 | 24 | " 25 | `; 26 | 27 | exports[`generate (--config-format) plugin-colon-prefix-name uses the right format 1`] = ` 28 | "## Rules 29 | 30 | 31 | 💼 Configurations enabled in.\\ 32 | ✅ Set in the \`plugin:test/recommended\` configuration. 33 | 34 | | Name | Description | 💼 | 35 | | :----------------------------- | :---------------------- | :- | 36 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ✅ | 37 | 38 | 39 | " 40 | `; 41 | 42 | exports[`generate (--config-format) plugin-colon-prefix-name uses the right format 2`] = ` 43 | "# Description for no-foo (\`test/no-foo\`) 44 | 45 | 💼 This rule is enabled in the ✅ \`plugin:test/recommended\` config. 46 | 47 | 48 | " 49 | `; 50 | 51 | exports[`generate (--config-format) prefix-name uses the right format 1`] = ` 52 | "## Rules 53 | 54 | 55 | 💼 Configurations enabled in.\\ 56 | ✅ Set in the \`test/recommended\` configuration. 57 | 58 | | Name | Description | 💼 | 59 | | :----------------------------- | :---------------------- | :- | 60 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ✅ | 61 | 62 | 63 | " 64 | `; 65 | 66 | exports[`generate (--config-format) prefix-name uses the right format 2`] = ` 67 | "# Description for no-foo (\`test/no-foo\`) 68 | 69 | 💼 This rule is enabled in the ✅ \`test/recommended\` config. 70 | 71 | 72 | " 73 | `; 74 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/cjs-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (cjs) basic generates the documentation 1`] = ` 4 | "# eslint-plugin-test 5 | 6 | 7 | 8 | | Name | Description | 9 | | :----------------------------- | :------------ | 10 | | [no-foo](docs/rules/no-foo.md) | disallow foo. | 11 | 12 | 13 | " 14 | `; 15 | 16 | exports[`generate (cjs) basic generates the documentation 2`] = ` 17 | "# Disallow foo (\`test/no-foo\`) 18 | 19 | 20 | " 21 | `; 22 | 23 | exports[`generate (cjs) config that extends another config generates the documentation 1`] = ` 24 | "# eslint-plugin-test 25 | 26 | ## Rules 27 | 28 | 29 | 30 | 💼 Configurations enabled in.\\ 31 | ✅ Set in the \`recommended\` configuration. 32 | 33 | | Name | Description | 💼 | 34 | | :----------------------------- | :--------------------- | :- | 35 | | [no-bar](docs/rules/no-bar.md) | Description of no-bar. | ✅ | 36 | | [no-baz](docs/rules/no-baz.md) | Description of no-baz. | ✅ | 37 | | [no-biz](docs/rules/no-biz.md) | Description of no-biz. | ✅ | 38 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | ✅ | 39 | 40 | 41 | " 42 | `; 43 | 44 | exports[`generate (cjs) config that extends another config generates the documentation 2`] = ` 45 | "# Description of no-foo (\`test/no-foo\`) 46 | 47 | 💼 This rule is enabled in the ✅ \`recommended\` config. 48 | 49 | 50 | " 51 | `; 52 | 53 | exports[`generate (cjs) config that extends another config generates the documentation 3`] = ` 54 | "# Description of no-bar (\`test/no-bar\`) 55 | 56 | 💼 This rule is enabled in the ✅ \`recommended\` config. 57 | 58 | 59 | " 60 | `; 61 | 62 | exports[`generate (cjs) config that extends another config generates the documentation 4`] = ` 63 | "# Description of no-baz (\`test/no-baz\`) 64 | 65 | 💼 This rule is enabled in the ✅ \`recommended\` config. 66 | 67 | 68 | " 69 | `; 70 | 71 | exports[`generate (cjs) config that extends another config generates the documentation 5`] = ` 72 | "# Description of no-biz (\`test/no-biz\`) 73 | 74 | 💼 This rule is enabled in the ✅ \`recommended\` config. 75 | 76 | 77 | " 78 | `; 79 | -------------------------------------------------------------------------------- /test/lib/generate/option-check-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve, join } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | import * as sinon from 'sinon'; 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | 11 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 12 | 13 | describe('generate (--check)', function () { 14 | describe('basic', function () { 15 | beforeEach(function () { 16 | mockFs({ 17 | 'package.json': JSON.stringify({ 18 | name: 'eslint-plugin-test', 19 | exports: 'index.js', 20 | type: 'module', 21 | }), 22 | 23 | 'index.js': ` 24 | export default { 25 | rules: { 26 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 27 | }, 28 | };`, 29 | 30 | 'README.md': '## Rules\n', 31 | 32 | 'docs/rules/no-foo.md': '# test/no-foo', 33 | 34 | // Needed for some of the test infrastructure to work. 35 | node_modules: mockFs.load(PATH_NODE_MODULES), 36 | }); 37 | }); 38 | 39 | afterEach(function () { 40 | mockFs.restore(); 41 | jest.resetModules(); 42 | }); 43 | 44 | it('prints the issues, exits with failure, and does not write changes', async function () { 45 | const consoleErrorStub = sinon.stub(console, 'error'); 46 | await generate('.', { check: true }); 47 | expect(consoleErrorStub.callCount).toBe(4); 48 | // Use join to handle both Windows and Unix paths. 49 | expect(consoleErrorStub.firstCall.args).toStrictEqual([ 50 | `Please run eslint-doc-generator. A rule doc is out-of-date: ${join( 51 | 'docs', 52 | 'rules', 53 | 'no-foo.md', 54 | )}`, 55 | ]); 56 | expect(consoleErrorStub.secondCall.args).toMatchSnapshot(); // Diff 57 | expect(consoleErrorStub.thirdCall.args).toStrictEqual([ 58 | 'Please run eslint-doc-generator. The rules table in README.md is out-of-date.', 59 | ]); 60 | expect(consoleErrorStub.getCall(3).args).toMatchSnapshot(); // Diff 61 | consoleErrorStub.restore(); 62 | 63 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 64 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /lib/emojis.ts: -------------------------------------------------------------------------------- 1 | import { SEVERITY_TYPE } from './types.js'; 2 | import { EMOJIS_TYPE } from './rule-type.js'; 3 | 4 | // Configs. 5 | const EMOJI_A11Y = '♿'; 6 | const EMOJI_ERROR = '❗'; 7 | const EMOJI_STYLE = '🎨'; 8 | const EMOJI_TYPESCRIPT = '⌨️'; 9 | const EMOJI_WARNING = '🚸'; 10 | /** Default emojis for common configs. */ 11 | export const EMOJI_CONFIGS = { 12 | a11y: EMOJI_A11Y, 13 | accessibility: EMOJI_A11Y, 14 | all: '🌐', 15 | error: EMOJI_ERROR, 16 | errors: EMOJI_ERROR, 17 | recommended: '✅', 18 | 'recommended-type-aware': '☑️', 19 | 'recommended-type-checked': '☑️', 20 | strict: '🔒', 21 | style: EMOJI_STYLE, 22 | stylistic: EMOJI_STYLE, 23 | ts: EMOJI_TYPESCRIPT, 24 | type: EMOJI_TYPESCRIPT, 25 | 'type-aware': EMOJI_TYPESCRIPT, 26 | 'type-checked': EMOJI_TYPESCRIPT, 27 | typed: EMOJI_TYPESCRIPT, 28 | types: EMOJI_TYPESCRIPT, 29 | typescript: EMOJI_TYPESCRIPT, 30 | warning: EMOJI_WARNING, 31 | warnings: EMOJI_WARNING, 32 | }; 33 | 34 | // Severities. 35 | export const EMOJI_CONFIG_ERROR = '💼'; 36 | export const EMOJI_CONFIG_WARN = '⚠️'; 37 | export const EMOJI_CONFIG_OFF = '🚫'; 38 | /** Emoji for each config severity. */ 39 | export const EMOJI_CONFIG_FROM_SEVERITY: { 40 | [key in SEVERITY_TYPE]: string; 41 | } = { 42 | [SEVERITY_TYPE.error]: EMOJI_CONFIG_ERROR, 43 | [SEVERITY_TYPE.warn]: EMOJI_CONFIG_WARN, 44 | [SEVERITY_TYPE.off]: EMOJI_CONFIG_OFF, 45 | }; 46 | 47 | /** Rule has an autofixer (from `meta.fixable`). */ 48 | export const EMOJI_FIXABLE = '🔧'; 49 | 50 | /** Rule provides suggestions (`meta.hasSuggestions`). */ 51 | export const EMOJI_HAS_SUGGESTIONS = '💡'; 52 | 53 | /** Rule options (from `meta.schema`). */ 54 | export const EMOJI_OPTIONS = '⚙️'; 55 | 56 | /** 57 | * Rule requires type-checking (from `meta.docs.requiresTypeChecking`). 58 | * Should match the emoji that @typescript-eslint/eslint-plugin uses for this (https://typescript-eslint.io/rules/). 59 | */ 60 | export const EMOJI_REQUIRES_TYPE_CHECKING = '💭'; 61 | 62 | /** 63 | * Rule type (from `meta.type`). 64 | * Also see EMOJIS_TYPE defined in rule-type.ts. 65 | */ 66 | export const EMOJI_TYPE = '🗂️'; 67 | 68 | /** Rule is deprecated (from `meta.deprecated`). */ 69 | export const EMOJI_DEPRECATED = '❌'; 70 | 71 | /** 72 | * The user is not allowed to specify a reserved emoji to represent their config because we use these emojis for other purposes. 73 | * Note that the default emojis for common configs are intentionally not reserved. 74 | */ 75 | export const RESERVED_EMOJIS = [ 76 | ...Object.values(EMOJI_CONFIG_FROM_SEVERITY), 77 | ...Object.values(EMOJIS_TYPE), 78 | 79 | EMOJI_FIXABLE, 80 | EMOJI_HAS_SUGGESTIONS, 81 | EMOJI_OPTIONS, 82 | EMOJI_REQUIRES_TYPE_CHECKING, 83 | EMOJI_TYPE, 84 | EMOJI_DEPRECATED, 85 | ]; 86 | -------------------------------------------------------------------------------- /test/lib/generate/cjs-test.ts: -------------------------------------------------------------------------------- 1 | // This file uses actual test fixtures on the file system instead of mock-fs due to 2 | // trouble with the combination of require() for loading CJS plugins, jest, and mock-fs. 3 | 4 | import { generate } from '../../../lib/generator.js'; 5 | import { join } from 'node:path'; 6 | import { readFileSync } from 'node:fs'; 7 | 8 | const FIXTURE_ROOT = join('test', 'fixtures'); // Relative to project root. 9 | 10 | describe('generate (cjs)', function () { 11 | describe('basic', function () { 12 | it('generates the documentation', async function () { 13 | const FIXTURE_PATH = join(FIXTURE_ROOT, 'cjs'); 14 | 15 | await generate(FIXTURE_PATH); 16 | 17 | expect( 18 | readFileSync(join(FIXTURE_PATH, 'README.md'), 'utf8'), 19 | ).toMatchSnapshot(); 20 | 21 | expect( 22 | readFileSync(join(FIXTURE_PATH, 'docs/rules/no-foo.md'), 'utf8'), 23 | ).toMatchSnapshot(); 24 | }); 25 | }); 26 | 27 | describe('Missing plugin package.json `main` field', function () { 28 | it('defaults to correct entry point', async function () { 29 | const FIXTURE_PATH = join(FIXTURE_ROOT, 'cjs-missing-main'); 30 | await expect(generate(FIXTURE_PATH)).resolves.toBeUndefined(); 31 | }); 32 | }); 33 | 34 | describe('package.json `main` field points to directory', function () { 35 | it('finds entry point', async function () { 36 | const FIXTURE_PATH = join(FIXTURE_ROOT, 'cjs-main-directory'); 37 | await expect(generate(FIXTURE_PATH)).resolves.toBeUndefined(); 38 | }); 39 | }); 40 | 41 | describe('config that extends another config', function () { 42 | it('generates the documentation', async function () { 43 | const FIXTURE_PATH = join(FIXTURE_ROOT, 'cjs-config-extends'); 44 | await generate(FIXTURE_PATH); 45 | expect( 46 | readFileSync(join(FIXTURE_PATH, 'README.md'), 'utf8'), 47 | ).toMatchSnapshot(); 48 | expect( 49 | readFileSync(join(FIXTURE_PATH, 'docs/rules/no-foo.md'), 'utf8'), 50 | ).toMatchSnapshot(); 51 | expect( 52 | readFileSync(join(FIXTURE_PATH, 'docs/rules/no-bar.md'), 'utf8'), 53 | ).toMatchSnapshot(); 54 | expect( 55 | readFileSync(join(FIXTURE_PATH, 'docs/rules/no-baz.md'), 'utf8'), 56 | ).toMatchSnapshot(); 57 | expect( 58 | readFileSync(join(FIXTURE_PATH, 'docs/rules/no-biz.md'), 'utf8'), 59 | ).toMatchSnapshot(); 60 | }); 61 | }); 62 | 63 | describe('package.json `main` field points to non-existent file', function () { 64 | it('throws an error', async function () { 65 | const FIXTURE_PATH = join(FIXTURE_ROOT, 'cjs-main-file-does-not-exist'); 66 | await expect(generate(FIXTURE_PATH)).rejects.toThrow( 67 | /Cannot find module/u, 68 | ); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/rule-type-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (rule type) rule with type, type column enabled displays the type 1`] = ` 4 | "## Rules 5 | 6 | 7 | 🗂️ The type of rule.\\ 8 | ❗ Identifies problems that could cause errors or unexpected behavior.\\ 9 | 📖 Identifies potential improvements.\\ 10 | 📏 Focuses on code formatting. 11 | 12 | | Name | 🗂️ | 13 | | :----------------------------- | :-- | 14 | | [no-bar](docs/rules/no-bar.md) | 📖 | 15 | | [no-biz](docs/rules/no-biz.md) | 📏 | 16 | | [no-boz](docs/rules/no-boz.md) | | 17 | | [no-buz](docs/rules/no-buz.md) | | 18 | | [no-foo](docs/rules/no-foo.md) | ❗ | 19 | 20 | 21 | " 22 | `; 23 | 24 | exports[`generate (rule type) rule with type, type column enabled displays the type 2`] = ` 25 | "# test/no-foo 26 | 27 | 28 | " 29 | `; 30 | 31 | exports[`generate (rule type) rule with type, type column enabled displays the type 3`] = ` 32 | "# test/no-bar 33 | 34 | 35 | " 36 | `; 37 | 38 | exports[`generate (rule type) rule with type, type column enabled displays the type 4`] = ` 39 | "# test/no-biz 40 | 41 | 42 | " 43 | `; 44 | 45 | exports[`generate (rule type) rule with type, type column enabled displays the type 5`] = ` 46 | "# test/no-boz 47 | 48 | 49 | " 50 | `; 51 | 52 | exports[`generate (rule type) rule with type, type column enabled displays the type 6`] = ` 53 | "# test/no-buz 54 | 55 | 56 | " 57 | `; 58 | 59 | exports[`generate (rule type) rule with type, type column enabled, but only an unknown type hides the type column and notice 1`] = ` 60 | "## Rules 61 | 62 | 63 | | Name | 64 | | :----------------------------- | 65 | | [no-foo](docs/rules/no-foo.md) | 66 | 67 | 68 | " 69 | `; 70 | 71 | exports[`generate (rule type) rule with type, type column enabled, but only an unknown type hides the type column and notice 2`] = ` 72 | "# test/no-foo 73 | 74 | 75 | " 76 | `; 77 | 78 | exports[`generate (rule type) rule with type, type column not enabled hides the type column 1`] = ` 79 | "## Rules 80 | 81 | 82 | | Name | 83 | | :----------------------------- | 84 | | [no-foo](docs/rules/no-foo.md) | 85 | 86 | 87 | " 88 | `; 89 | 90 | exports[`generate (rule type) rule with type, type column not enabled hides the type column 2`] = ` 91 | "# test/no-foo 92 | 93 | 94 | " 95 | `; 96 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/package-json-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (package.json) Missing plugin package.json \`name\` field throws an error 1`] = `"Could not find \`name\` field in ESLint plugin's package.json."`; 4 | 5 | exports[`generate (package.json) Missing plugin package.json throws an error 1`] = `"Could not find package.json of ESLint plugin."`; 6 | 7 | exports[`generate (package.json) No configs found omits the config column 1`] = ` 8 | " 9 | 10 | | Name | Description | 11 | | :----------------------------- | :------------ | 12 | | [no-foo](docs/rules/no-foo.md) | disallow foo. | 13 | 14 | " 15 | `; 16 | 17 | exports[`generate (package.json) No configs found omits the config column 2`] = ` 18 | "# Disallow foo (\`test/no-foo\`) 19 | 20 | 21 | " 22 | `; 23 | 24 | exports[`generate (package.json) No exported rules object found throws an error 1`] = `"Could not find exported \`rules\` object in ESLint plugin."`; 25 | 26 | exports[`generate (package.json) Scoped plugin name determines the correct plugin prefix 1`] = ` 27 | "# Disallow foo (\`@my-scope/no-foo\`) 28 | 29 | 💼 This rule is enabled in the ✅ \`recommended\` config. 30 | 31 | 32 | " 33 | `; 34 | 35 | exports[`generate (package.json) Scoped plugin with custom plugin name determines the correct plugin prefix 1`] = ` 36 | " 37 | 38 | 💼 Configurations enabled in.\\ 39 | ✅ Set in the \`recommended\` configuration. 40 | 41 | | Name | Description | 💼 | 42 | | :----------------------------- | :------------ | :- | 43 | | [no-foo](docs/rules/no-foo.md) | disallow foo. | ✅ | 44 | 45 | " 46 | `; 47 | 48 | exports[`generate (package.json) Scoped plugin with custom plugin name determines the correct plugin prefix 2`] = ` 49 | "# Disallow foo (\`@my-scope/foo/no-foo\`) 50 | 51 | 💼 This rule is enabled in the ✅ \`recommended\` config. 52 | 53 | 54 | " 55 | `; 56 | 57 | exports[`generate (package.json) plugin entry point in JSON format generates the documentation 1`] = ` 58 | " 59 | 60 | 💼 Configurations enabled in.\\ 61 | ✅ Set in the \`recommended\` configuration. 62 | 63 | | Name | Description | 💼 | 64 | | :----------------------------- | :--------------------- | :- | 65 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo | ✅ | 66 | 67 | " 68 | `; 69 | 70 | exports[`generate (package.json) plugin entry point in JSON format generates the documentation 2`] = ` 71 | "# Description for no-foo (\`test/no-foo\`) 72 | 73 | 💼 This rule is enabled in the ✅ \`recommended\` config. 74 | 75 | 76 | " 77 | `; 78 | -------------------------------------------------------------------------------- /lib/rule-options.ts: -------------------------------------------------------------------------------- 1 | import traverse from 'json-schema-traverse'; 2 | import type { 3 | JSONSchema4, 4 | JSONSchema4Type, 5 | JSONSchema4TypeName, 6 | } from 'json-schema'; 7 | import { capitalizeOnlyFirstLetter } from './string.js'; 8 | 9 | export type RuleOption = { 10 | name: string; 11 | type?: string; 12 | description?: string; 13 | required?: boolean; 14 | enum?: readonly JSONSchema4Type[]; 15 | default?: JSONSchema4Type; 16 | deprecated?: boolean; 17 | }; 18 | 19 | function typeToString( 20 | type: JSONSchema4TypeName[] | JSONSchema4TypeName, 21 | ): string { 22 | return Array.isArray(type) 23 | ? type.map((item) => capitalizeOnlyFirstLetter(item)).join(', ') 24 | : capitalizeOnlyFirstLetter(type); 25 | } 26 | 27 | /** 28 | * Gather a list of named options from a rule schema. 29 | * @param jsonSchema - the JSON schema to check 30 | * @returns - list of named options we could detect from the schema 31 | */ 32 | export function getAllNamedOptions( 33 | jsonSchema: JSONSchema4 | readonly JSONSchema4[] | undefined | null, 34 | ): readonly RuleOption[] { 35 | if (!jsonSchema) { 36 | return []; 37 | } 38 | 39 | if (Array.isArray(jsonSchema)) { 40 | return jsonSchema.flatMap((js: JSONSchema4) => getAllNamedOptions(js)); 41 | } 42 | 43 | const options: RuleOption[] = []; 44 | traverse(jsonSchema, (js: JSONSchema4) => { 45 | if (js.properties) { 46 | options.push( 47 | ...Object.entries(js.properties).map(([key, value]) => ({ 48 | name: key, 49 | type: 50 | value.type === 'array' && 51 | !Array.isArray(value.items) && 52 | value.items?.type 53 | ? `${ 54 | Array.isArray(value.items.type) && value.items.type.length > 1 55 | ? `(${typeToString(value.items.type)})` 56 | : typeToString(value.items.type) 57 | }[]` 58 | : value.type 59 | ? typeToString(value.type) 60 | : undefined, 61 | description: value.description, 62 | default: value.default, 63 | enum: value.enum, 64 | required: 65 | typeof value.required === 'boolean' 66 | ? value.required 67 | : Array.isArray(js.required) && js.required.includes(key), 68 | deprecated: value.deprecated, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- property exists on future JSONSchema version but we can let it be used anyway. 69 | })), 70 | ); 71 | } 72 | }); 73 | return options; 74 | } 75 | 76 | /** 77 | * Check if a rule schema is non-blank/empty and thus has actual options. 78 | * @param jsonSchema - the JSON schema to check 79 | * @returns - whether the schema has options 80 | */ 81 | export function hasOptions( 82 | jsonSchema: JSONSchema4 | readonly JSONSchema4[], 83 | ): boolean { 84 | return ( 85 | (Array.isArray(jsonSchema) && jsonSchema.length > 0) || 86 | (typeof jsonSchema === 'object' && Object.keys(jsonSchema).length > 0) 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '38 5 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'typescript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v6 42 | with: 43 | persist-credentials: false 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v4 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v4 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v4 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /lib/plugin-config-resolution.ts: -------------------------------------------------------------------------------- 1 | import { TSESLint } from '@typescript-eslint/utils'; 2 | import { 3 | ClassicConfig, 4 | FlatConfig, 5 | } from '@typescript-eslint/utils/dist/ts-eslint'; // eslint-disable-line import/extensions -- false positive 6 | import { existsSync } from 'node:fs'; 7 | import { importAbs } from './import.js'; 8 | import type { Config, ConfigsToRules, Plugin, Rules } from './types.js'; 9 | 10 | /** 11 | * ESLint configs can extend other configs, so for convenience, let's resolve all the rules in each config upfront. 12 | */ 13 | export async function resolveConfigsToRules( 14 | plugin: Plugin, 15 | ): Promise { 16 | const configs: Record = {}; 17 | 18 | for (const [configName, config] of Object.entries(plugin.configs || {})) { 19 | configs[configName] = await resolvePotentiallyFlatConfigs(config); 20 | } 21 | 22 | return configs; 23 | } 24 | 25 | /** 26 | * Check whether the passed config is an array and iterate over it 27 | */ 28 | async function resolvePotentiallyFlatConfigs( 29 | potentiallyFlatConfigs: TSESLint.Linter.ConfigType, 30 | ): Promise { 31 | const rules: Rules = {}; 32 | 33 | if (Array.isArray(potentiallyFlatConfigs)) { 34 | for (const config of potentiallyFlatConfigs) { 35 | Object.assign(rules, await resolvePotentiallyFlatConfigs(config)); 36 | } 37 | } else { 38 | Object.assign(rules, await resolveConfigRules(potentiallyFlatConfigs)); 39 | } 40 | 41 | return rules; 42 | } 43 | 44 | /** 45 | * Recursively gather all the rules from a config that may extend other configs. 46 | */ 47 | async function resolveConfigRules( 48 | config: ClassicConfig.Config | FlatConfig.Config, 49 | ): Promise { 50 | const rules = { ...config.rules }; 51 | 52 | if ('overrides' in config && config.overrides) { 53 | for (const override of config.overrides) { 54 | Object.assign(rules, override.rules); 55 | const extendedRulesFromOverride = await resolveConfigExtends( 56 | override.extends || [], 57 | ); 58 | Object.assign(rules, extendedRulesFromOverride); 59 | } 60 | } 61 | 62 | if ('extends' in config && config.extends) { 63 | const extendedRules = await resolveConfigExtends(config.extends); 64 | Object.assign(rules, extendedRules); 65 | } 66 | 67 | return rules; 68 | } 69 | 70 | async function resolveConfigExtends( 71 | extendItems: readonly string[] | string, 72 | ): Promise { 73 | const rules: Rules = {}; 74 | 75 | // eslint-disable-next-line unicorn/no-instanceof-builtins -- using Array.isArray() loses type information about the array. 76 | for (const extend of extendItems instanceof Array 77 | ? extendItems 78 | : [extendItems]) { 79 | if ( 80 | ['plugin:', 'eslint:'].some((prefix) => extend.startsWith(prefix)) || 81 | !existsSync(extend) 82 | ) { 83 | // Ignore external configs. 84 | continue; 85 | } 86 | 87 | const { default: config } = (await importAbs(extend)) as { 88 | default: Config; 89 | }; 90 | const extendedRules = await resolveConfigRules(config); 91 | Object.assign(rules, extendedRules); 92 | } 93 | 94 | return rules; 95 | } 96 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-rule-list-columns-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--rule-list-columns) basic shows the right columns and legend 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\\ 8 | 🔧 Automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). 9 | 10 | | 💡 | 🔧 | Name | 11 | | :- | :- | :----------------------------- | 12 | | 💡 | 🔧 | [no-foo](docs/rules/no-foo.md) | 13 | 14 | 15 | " 16 | `; 17 | 18 | exports[`generate (--rule-list-columns) basic shows the right columns and legend 2`] = ` 19 | "# Description for no-foo (\`test/no-foo\`) 20 | 21 | ❌ This rule is deprecated. 22 | 23 | 🔧💡 This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 24 | 25 | 26 | " 27 | `; 28 | 29 | exports[`generate (--rule-list-columns) consolidated fixableAndHasSuggestions column shows the right columns and legend 1`] = ` 30 | "## Rules 31 | 32 | 33 | 🔧 Automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\\ 34 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 35 | 36 | | Name | 🔧💡 | 37 | | :----------------------------- | :--- | 38 | | [no-bar](docs/rules/no-bar.md) | 🔧 | 39 | | [no-baz](docs/rules/no-baz.md) | | 40 | | [no-foo](docs/rules/no-foo.md) | 🔧💡 | 41 | 42 | 43 | " 44 | `; 45 | 46 | exports[`generate (--rule-list-columns) shows column and notice for requiresTypeChecking updates the documentation 1`] = ` 47 | " 48 | 49 | 💼 Configurations enabled in.\\ 50 | 🌐 Set in the \`all\` configuration.\\ 51 | 💭 Requires [type information](https://typescript-eslint.io/linting/typed-linting). 52 | 53 | | Name | Description | 💼 | 💭 | 54 | | :----------------------------- | :--------------------- | :- | :- | 55 | | [no-bar](docs/rules/no-bar.md) | Description of no-bar. | | 💭 | 56 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 🌐 | | 57 | 58 | " 59 | `; 60 | 61 | exports[`generate (--rule-list-columns) shows column and notice for requiresTypeChecking updates the documentation 2`] = ` 62 | "# Description of no-foo (\`test/no-foo\`) 63 | 64 | 💼 This rule is enabled in the 🌐 \`all\` config. 65 | 66 | 67 | " 68 | `; 69 | 70 | exports[`generate (--rule-list-columns) shows column and notice for requiresTypeChecking updates the documentation 3`] = ` 71 | "# Description of no-bar (\`test/no-bar\`) 72 | 73 | 💭 This rule requires [type information](https://typescript-eslint.io/linting/typed-linting). 74 | 75 | 76 | " 77 | `; 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-doc-generator", 3 | "version": "2.4.0", 4 | "description": "Automatic documentation generator for ESLint plugins and rules.", 5 | "keywords": [ 6 | "doc", 7 | "docs", 8 | "documentation", 9 | "eslint", 10 | "generator", 11 | "plugin" 12 | ], 13 | "homepage": "https://github.com/bmish/eslint-doc-generator#readme", 14 | "bugs": { 15 | "url": "https://github.com/bmish/eslint-doc-generator/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/bmish/eslint-doc-generator.git" 20 | }, 21 | "license": "ISC", 22 | "author": "Bryan Mishkin", 23 | "type": "module", 24 | "types": "./dist/lib/index.d.ts", 25 | "bin": { 26 | "eslint-doc-generator": "./dist/bin/eslint-doc-generator.js" 27 | }, 28 | "files": [ 29 | "dist/", 30 | "README.md" 31 | ], 32 | "scripts": { 33 | "build": "rm -rf dist && tsc --project tsconfig.build.json", 34 | "lint": "npm-run-all --continue-on-error --aggregate-output --parallel \"lint:*\"", 35 | "lint:docs": "markdownlint \"**/*.md\"", 36 | "lint:docs:fix": "npm run lint:docs -- --fix", 37 | "lint:js": "eslint --cache .", 38 | "lint:js:fix": "npm run lint:js -- --fix", 39 | "lint:package-json": "npmPkgJsonLint .", 40 | "lint:package-json-sorting": "sort-package-json --check", 41 | "lint:package-json-sorting:fix": "sort-package-json package.json", 42 | "lint:types": "tsc", 43 | "prepublishOnly": "npm run build", 44 | "test": "node --max-old-space-size=4096 --experimental-vm-modules node_modules/jest/bin/jest.js --colors --coverage" 45 | }, 46 | "dependencies": { 47 | "@typescript-eslint/utils": "^8.0.0", 48 | "ajv": "^8.11.2", 49 | "change-case": "^5.0.0", 50 | "commander": "^14.0.0", 51 | "cosmiconfig": "^9.0.0", 52 | "deepmerge": "^4.2.2", 53 | "dot-prop": "^9.0.0", 54 | "editorconfig": "^3.0.1", 55 | "jest-diff": "^29.2.1", 56 | "json-schema": "^0.4.0", 57 | "json-schema-traverse": "^1.0.0", 58 | "markdown-table": "^3.0.3", 59 | "type-fest": "^4.0.0" 60 | }, 61 | "devDependencies": { 62 | "@eslint/js": "^9.29.0", 63 | "@jest/globals": "^29.1.2", 64 | "@types/jest": "^29.5.14", 65 | "@types/mock-fs": "^4.13.4", 66 | "@types/node": "^24.0.3", 67 | "@types/sinon": "^17.0.0", 68 | "eslint": "^9.29.0", 69 | "eslint-config-prettier": "^10.1.5", 70 | "eslint-plugin-import": "^2.32.0", 71 | "eslint-plugin-jest": "^28.0.0", 72 | "eslint-plugin-n": "^17.20.0", 73 | "eslint-plugin-prettier": "^5.5.0", 74 | "eslint-plugin-unicorn": "^59.0.1", 75 | "jest": "^29.1.1", 76 | "markdownlint-cli": "^0.45.0", 77 | "mock-fs": "^5.1.4", 78 | "npm-package-json-lint": "^8.0.0", 79 | "npm-run-all": "^4.1.5", 80 | "outdent": "^0.8.0", 81 | "prettier": "^3.4.2", 82 | "sinon": "^20.0.0", 83 | "sort-package-json": "^3.0.0", 84 | "ts-jest": "^29.2.5", 85 | "ts-node": "^10.9.2", 86 | "typescript": "^5.0.4", 87 | "typescript-eslint": "^8.0.0" 88 | }, 89 | "peerDependencies": { 90 | "eslint": ">= 8.57.1", 91 | "prettier": ">= 3.0.0" 92 | }, 93 | "peerDependenciesMeta": { 94 | "prettier": { 95 | "optional": true 96 | } 97 | }, 98 | "engines": { 99 | "node": "^18.18.0 || ^20.9.0 || >=22.0.0" 100 | }, 101 | "publishConfig": { 102 | "registry": "https://registry.npmjs.org" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /test/lib/generate/option-rule-doc-section-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { jest } from '@jest/globals'; 6 | import * as sinon from 'sinon'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (rule doc sections)', function () { 13 | describe('with `--rule-doc-section-include` and `--rule-doc-section-exclude` and no problems', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 26 | }, 27 | };`, 28 | 29 | 'README.md': '## Rules\n', 30 | 31 | 'docs/rules/no-foo.md': '## Examples\n', 32 | 33 | // Needed for some of the test infrastructure to work. 34 | node_modules: mockFs.load(PATH_NODE_MODULES), 35 | }); 36 | }); 37 | 38 | afterEach(function () { 39 | mockFs.restore(); 40 | jest.resetModules(); 41 | }); 42 | 43 | it('has no issues', async function () { 44 | await expect( 45 | generate('.', { 46 | ruleDocSectionInclude: ['Examples'], 47 | ruleDocSectionExclude: ['Unwanted Section'], 48 | }), 49 | ).resolves.toBeUndefined(); 50 | }); 51 | }); 52 | 53 | describe('with `--rule-doc-section-include` and `--rule-doc-section-exclude` and problems', function () { 54 | beforeEach(function () { 55 | mockFs({ 56 | 'package.json': JSON.stringify({ 57 | name: 'eslint-plugin-test', 58 | exports: 'index.js', 59 | type: 'module', 60 | }), 61 | 62 | 'index.js': ` 63 | export default { 64 | rules: { 65 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 66 | }, 67 | };`, 68 | 69 | 'README.md': '## Rules\n', 70 | 71 | 'docs/rules/no-foo.md': '## Unwanted Section\n', 72 | 73 | // Needed for some of the test infrastructure to work. 74 | node_modules: mockFs.load(PATH_NODE_MODULES), 75 | }); 76 | }); 77 | 78 | afterEach(function () { 79 | mockFs.restore(); 80 | jest.resetModules(); 81 | }); 82 | 83 | it('prints errors', async function () { 84 | const consoleErrorStub = sinon.stub(console, 'error'); 85 | await generate('.', { 86 | ruleDocSectionInclude: ['Examples'], 87 | ruleDocSectionExclude: ['Unwanted Section'], 88 | }); 89 | expect(consoleErrorStub.callCount).toBe(2); 90 | expect(consoleErrorStub.firstCall.args).toStrictEqual([ 91 | '`no-foo` rule doc should have included the header: Examples', 92 | ]); 93 | expect(consoleErrorStub.secondCall.args).toStrictEqual([ 94 | '`no-foo` rule doc should not have included the header: Unwanted Section', 95 | ]); 96 | consoleErrorStub.restore(); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/lib/generate/option-url-configs-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (--url-configs)', function () { 13 | describe('basic', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 26 | 'no-bar': { meta: { docs: { description: 'Description for no-bar.'} }, create(context) {} }, 27 | }, 28 | configs: { 29 | recommended: { 30 | rules: { 31 | 'test/no-foo': 'error', 32 | } 33 | }, 34 | customConfig: { 35 | rules: { 36 | 'test/no-bar': 'error', 37 | } 38 | }, 39 | } 40 | };`, 41 | 42 | 'README.md': '## Rules\n', 43 | 44 | 'docs/rules/no-foo.md': '', 45 | 'docs/rules/no-bar.md': '', 46 | 47 | // Needed for some of the test infrastructure to work. 48 | node_modules: mockFs.load(PATH_NODE_MODULES), 49 | }); 50 | }); 51 | 52 | afterEach(function () { 53 | mockFs.restore(); 54 | jest.resetModules(); 55 | }); 56 | 57 | it('includes the config link', async function () { 58 | await generate('.', { 59 | urlConfigs: 'https://example.com/configs', 60 | }); 61 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 62 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 63 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 64 | }); 65 | }); 66 | 67 | describe('with only recommended config', function () { 68 | beforeEach(function () { 69 | mockFs({ 70 | 'package.json': JSON.stringify({ 71 | name: 'eslint-plugin-test', 72 | exports: 'index.js', 73 | type: 'module', 74 | }), 75 | 76 | 'index.js': ` 77 | export default { 78 | rules: { 79 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 80 | }, 81 | configs: { 82 | recommended: { 83 | rules: { 84 | 'test/no-foo': 'error', 85 | } 86 | }, 87 | } 88 | };`, 89 | 90 | 'README.md': '## Rules\n', 91 | 92 | 'docs/rules/no-foo.md': '', 93 | 94 | // Needed for some of the test infrastructure to work. 95 | node_modules: mockFs.load(PATH_NODE_MODULES), 96 | }); 97 | }); 98 | 99 | afterEach(function () { 100 | mockFs.restore(); 101 | jest.resetModules(); 102 | }); 103 | 104 | it('includes the config link', async function () { 105 | await generate('.', { 106 | urlConfigs: 'https://example.com/configs', 107 | }); 108 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 109 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /lib/config-list.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BEGIN_CONFIG_LIST_MARKER, 3 | END_CONFIG_LIST_MARKER, 4 | } from './comment-markers.js'; 5 | import { markdownTable } from 'markdown-table'; 6 | import type { Config } from './types.js'; 7 | import { configNameToDisplay } from './config-format.js'; 8 | import { sanitizeMarkdownTable } from './string.js'; 9 | import { Context } from './context.js'; 10 | 11 | /** 12 | * Check potential locations for the config description. 13 | * These are not official properties. 14 | * The recommended/allowed way to add a description is still pending the outcome of: https://github.com/eslint/eslint/issues/17842 15 | * @param config 16 | * @returns the description if available 17 | */ 18 | function configToDescription(config: Config): string | undefined { 19 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 20 | return ( 21 | // @ts-expect-error -- description is not an official config property. 22 | config.description || 23 | // @ts-expect-error -- description is not an official config property. 24 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 25 | config.meta?.description || 26 | // @ts-expect-error -- description is not an official config property. 27 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 28 | config.meta?.docs?.description 29 | ); 30 | } 31 | 32 | function generateConfigListMarkdown(context: Context): string { 33 | const { configsToRules, options, plugin } = context; 34 | const { configEmojis, ignoreConfig } = options; 35 | 36 | /* istanbul ignore next -- configs are sure to exist at this point */ 37 | const configs = Object.values(plugin.configs || {}); 38 | const hasDescription = configs.some((config) => configToDescription(config)); 39 | const listHeaderRow = ['', 'Name']; 40 | if (hasDescription) { 41 | listHeaderRow.push('Description'); 42 | } 43 | 44 | const rows = [ 45 | listHeaderRow, 46 | ...Object.keys(configsToRules) 47 | .filter((configName) => !ignoreConfig.includes(configName)) 48 | .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) 49 | .map((configName) => { 50 | const config = plugin.configs?.[configName]; 51 | /* istanbul ignore next -- config should exist at this point */ 52 | const description = config ? configToDescription(config) : undefined; 53 | return [ 54 | configEmojis.find((obj) => obj.config === configName)?.emoji || '', 55 | `\`${configNameToDisplay(context, configName)}\``, 56 | hasDescription ? description || '' : undefined, 57 | ].filter((col) => col !== undefined); 58 | }), 59 | ]; 60 | 61 | return markdownTable( 62 | sanitizeMarkdownTable(context, rows), 63 | { align: 'l' }, // Left-align headers. 64 | ); 65 | } 66 | 67 | export function updateConfigsList(context: Context, markdown: string): string { 68 | const { configsToRules, endOfLine, options } = context; 69 | const { ignoreConfig } = options; 70 | 71 | const listStartIndex = markdown.indexOf(BEGIN_CONFIG_LIST_MARKER); 72 | let listEndIndex = markdown.indexOf(END_CONFIG_LIST_MARKER); 73 | 74 | if (listStartIndex === -1 || listEndIndex === -1) { 75 | // No config list found. 76 | return markdown; 77 | } 78 | 79 | if ( 80 | Object.keys(configsToRules).filter( 81 | (configName) => !ignoreConfig.includes(configName), 82 | ).length === 0 83 | ) { 84 | // No non-ignored configs found. 85 | return markdown; 86 | } 87 | 88 | // Account for length of pre-existing marker. 89 | listEndIndex += END_CONFIG_LIST_MARKER.length; 90 | 91 | const preList = markdown.slice(0, Math.max(0, listStartIndex)); 92 | const postList = markdown.slice(Math.max(0, listEndIndex)); 93 | 94 | // New config list. 95 | const list = generateConfigListMarkdown(context); 96 | 97 | return `${preList}${BEGIN_CONFIG_LIST_MARKER}${endOfLine}${endOfLine}${list}${endOfLine}${endOfLine}${END_CONFIG_LIST_MARKER}${postList}`; 98 | } 99 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/rule-options-list-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (rule options list) basic generates the documentation 1`] = ` 4 | "# test/no-foo 5 | 6 | 7 | ## Options 8 | 9 | 10 | | Name | Description | Type | Choices | Default | Required | Deprecated | 11 | | :------------------------- | :---------------------------- | :------------------ | :---------------- | :------------------------------------- | :------- | :--------- | 12 | | \`arr1\` | | Array | | | | | 13 | | \`arrWithArrType\` | | String, Boolean | | | | | 14 | | \`arrWithArrTypeSingleItem\` | | String | | | | | 15 | | \`arrWithDefault\` | | Array | | [\`hello world\`, \`1\`, \`2\`, \`3\`, \`true\`] | | | 16 | | \`arrWithDefaultEmpty\` | | Array | | \`[]\` | | | 17 | | \`arrWithItemsArrayType\` | | (String, Boolean)[] | | | | | 18 | | \`arrWithItemsType\` | | String[] | | | | | 19 | | \`bar\` | Choose how to use the rule. | String | \`always\`, \`never\` | \`always\` | Yes | | 20 | | \`baz\` | | | | \`true\` | Yes | | 21 | | \`biz\` | | | | | | | 22 | | \`foo\` | Enable some kind of behavior. | Boolean | | \`false\` | | Yes | 23 | 24 | " 25 | `; 26 | 27 | exports[`generate (rule options list) displays default column even when only falsy value, hiding deprecated/required cols with only falsy value generates the documentation 1`] = ` 28 | "# test/no-foo 29 | 30 | 31 | ## Options 32 | 33 | 34 | | Name | Default | 35 | | :---- | :------ | 36 | | \`foo\` | \`false\` | 37 | 38 | " 39 | `; 40 | 41 | exports[`generate (rule options list) with no marker comments generates the documentation 1`] = ` 42 | "# test/no-foo 43 | 44 | 45 | ## Options 46 | foo" 47 | `; 48 | 49 | exports[`generate (rule options list) with no options generates the documentation 1`] = ` 50 | "# test/no-foo 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | " 59 | `; 60 | 61 | exports[`generate (rule options list) with string that needs to be escaped in table generates the documentation 1`] = ` 62 | "# test/no-foo 63 | 64 | 65 | ## Options 66 | 67 | 68 | | Name | Description | Type | 69 | | :---- | :------------------------------ | :------------- | 70 | | \`foo\` | test
desc | String\\|number | 71 | 72 | " 73 | `; 74 | -------------------------------------------------------------------------------- /lib/rule-link.ts: -------------------------------------------------------------------------------- 1 | import { join, sep, relative, dirname } from 'node:path'; 2 | import { PathRuleDocFunction, RULE_SOURCE } from './types.js'; 3 | import { getPluginRoot } from './package-json.js'; 4 | import { Context } from './context.js'; 5 | 6 | export function replaceRulePlaceholder( 7 | pathOrPathFunc: string | PathRuleDocFunction, 8 | ruleName: string, 9 | ) { 10 | return typeof pathOrPathFunc === 'function' 11 | ? pathOrPathFunc(ruleName) 12 | : pathOrPathFunc.replaceAll('{name}', ruleName); 13 | } 14 | 15 | /** 16 | * Account for how Windows paths use backslashes instead of the forward slashes that URLs require. 17 | */ 18 | function pathToUrl(path: string): string { 19 | return path.split(sep).join('/'); 20 | } 21 | 22 | /** 23 | * Get the link to a rule's documentation page. 24 | * Will be relative to the current page. 25 | */ 26 | export function getUrlToRule( 27 | context: Context, 28 | ruleName: string, 29 | ruleSource: RULE_SOURCE, 30 | pathToFile: string, 31 | ) { 32 | const { options, path, pluginPrefix } = context; 33 | const { pathRuleDoc, urlRuleDoc } = options; 34 | 35 | switch (ruleSource) { 36 | case RULE_SOURCE.eslintCore: { 37 | return `https://eslint.org/docs/latest/rules/${ruleName}`; 38 | } 39 | case RULE_SOURCE.thirdPartyPlugin: { 40 | // We don't know the documentation URL to third-party plugins. 41 | return undefined; 42 | } 43 | default: { 44 | // Fallthrough to remaining logic in function. 45 | break; 46 | } 47 | } 48 | 49 | // Ignore plugin prefix if it's included in rule name. 50 | // While we could display the prefix if we wanted, it definitely cannot be part of the link. 51 | const ruleNameWithoutPluginPrefix = ruleName.startsWith(`${pluginPrefix}/`) 52 | ? ruleName.slice(pluginPrefix.length + 1) 53 | : ruleName; 54 | 55 | // If the URL is a function, evaluate it. 56 | const urlRuleDocFunctionEvaluated = 57 | typeof urlRuleDoc === 'function' 58 | ? urlRuleDoc(ruleName, pathToUrl(relative(path, pathToFile))) 59 | : undefined; 60 | 61 | const pathRuleDocEvaluated = join( 62 | getPluginRoot(path), 63 | replaceRulePlaceholder(pathRuleDoc, ruleNameWithoutPluginPrefix), 64 | ); 65 | 66 | return ( 67 | // If the function returned a URL, use it. 68 | urlRuleDocFunctionEvaluated ?? 69 | (typeof urlRuleDoc === 'string' 70 | ? // Otherwise, use the URL if it's a string. 71 | replaceRulePlaceholder(urlRuleDoc, ruleNameWithoutPluginPrefix) 72 | : // Finally, fallback to the relative path. 73 | pathToUrl(relative(dirname(pathToFile), pathRuleDocEvaluated))) 74 | ); 75 | } 76 | 77 | /** 78 | * Get the markdown link (title and URL) to the rule's documentation. 79 | */ 80 | export function getLinkToRule( 81 | context: Context, 82 | ruleName: string, 83 | pathToFile: string, 84 | includeBackticks: boolean, 85 | includePrefix: boolean, 86 | ) { 87 | const { plugin, pluginPrefix } = context; 88 | 89 | const ruleNameWithoutPluginPrefix = ruleName.startsWith(`${pluginPrefix}/`) 90 | ? ruleName.slice(pluginPrefix.length + 1) 91 | : ruleName; 92 | 93 | // Determine what plugin this rule comes from. 94 | let ruleSource: RULE_SOURCE; 95 | if (plugin.rules?.[ruleNameWithoutPluginPrefix]) { 96 | ruleSource = RULE_SOURCE.self; 97 | } else if (ruleName.includes('/')) { 98 | // Assume a slash is for the plugin prefix (ESLint core doesn't have any nested rules). 99 | ruleSource = RULE_SOURCE.thirdPartyPlugin; 100 | } else { 101 | ruleSource = RULE_SOURCE.eslintCore; 102 | } 103 | 104 | const ruleNameWithPluginPrefix = ruleName.startsWith(`${pluginPrefix}/`) 105 | ? ruleName 106 | : ruleSource === RULE_SOURCE.self 107 | ? `${pluginPrefix}/${ruleName}` 108 | : undefined; 109 | 110 | const urlToRule = getUrlToRule(context, ruleName, ruleSource, pathToFile); 111 | 112 | const ruleNameToDisplay = `${includeBackticks ? '`' : ''}${ 113 | includePrefix && ruleNameWithPluginPrefix 114 | ? ruleNameWithPluginPrefix 115 | : ruleNameWithoutPluginPrefix 116 | }${includeBackticks ? '`' : ''}`; 117 | 118 | return urlToRule ? `[${ruleNameToDisplay}](${urlToRule})` : ruleNameToDisplay; 119 | } 120 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/file-paths-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (file paths) custom path to rule docs and rules list generates the documentation 1`] = ` 4 | " 5 | 6 | | Name | 7 | | :------------------------- | 8 | | [no-foo](no-foo/no-foo.md) | 9 | 10 | " 11 | `; 12 | 13 | exports[`generate (file paths) custom path to rule docs and rules list generates the documentation 2`] = ` 14 | "# test/no-foo 15 | 16 | 17 | " 18 | `; 19 | 20 | exports[`generate (file paths) custom path to rule docs and rules list generates the documentation using a function for pathRuleDoc 1`] = ` 21 | " 22 | 23 | | Name | 24 | | :------------------------------- | 25 | | [no-foo](rules/no-foo/no-foo.md) | 26 | 27 | " 28 | `; 29 | 30 | exports[`generate (file paths) custom path to rule docs and rules list generates the documentation using a function for pathRuleDoc 2`] = ` 31 | "# test/no-foo 32 | 33 | 34 | " 35 | `; 36 | 37 | exports[`generate (file paths) empty array of rule lists (happens when CLI option is not passed) falls back to default rules list 1`] = ` 38 | " 39 | 40 | | Name | 41 | | :----------------------------- | 42 | | [no-foo](docs/rules/no-foo.md) | 43 | 44 | " 45 | `; 46 | 47 | exports[`generate (file paths) lowercase README file generates the documentation 1`] = ` 48 | " 49 | 50 | | Name | 51 | | :----------------------------- | 52 | | [no-foo](docs/rules/no-foo.md) | 53 | 54 | " 55 | `; 56 | 57 | exports[`generate (file paths) missing rule doc when initRuleDocs is true creates the rule doc 1`] = ` 58 | "# test/no-foo 59 | 60 | 61 | " 62 | `; 63 | 64 | exports[`generate (file paths) missing rule doc when initRuleDocs is true creates the rule doc 2`] = ` 65 | "# test/no-bar 66 | 67 | 68 | 69 | ## Options 70 | 71 | 72 | 73 | | Name | 74 | | :-------- | 75 | | \`option1\` | 76 | 77 | 78 | " 79 | `; 80 | 81 | exports[`generate (file paths) missing rule doc, initRuleDocs is true, and with ruleDocSectionInclude creates the rule doc including the mandatory section 1`] = ` 82 | "# test/no-foo 83 | 84 | 85 | 86 | ## Examples 87 | " 88 | `; 89 | 90 | exports[`generate (file paths) missing rule doc, initRuleDocs is true, and with ruleDocSectionInclude creates the rule doc including the mandatory section 2`] = ` 91 | "# test/no-bar 92 | 93 | 94 | 95 | ## Examples 96 | 97 | ## Options 98 | 99 | 100 | 101 | | Name | 102 | | :-------- | 103 | | \`option1\` | 104 | 105 | 106 | " 107 | `; 108 | 109 | exports[`generate (file paths) multiple rules lists generates the documentation 1`] = ` 110 | " 111 | 112 | | Name | 113 | | :----------------------------- | 114 | | [no-foo](docs/rules/no-foo.md) | 115 | 116 | " 117 | `; 118 | 119 | exports[`generate (file paths) multiple rules lists generates the documentation 2`] = ` 120 | " 121 | 122 | | Name | 123 | | :-------------------------------- | 124 | | [no-foo](../docs/rules/no-foo.md) | 125 | 126 | " 127 | `; 128 | 129 | exports[`generate (file paths) multiple rules lists generates the documentation 3`] = ` 130 | " 131 | 132 | | Name | 133 | | :------------------ | 134 | | [no-foo](no-foo.md) | 135 | 136 | " 137 | `; 138 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/rule-description-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (rule descriptions) Rule description needs to be formatted capitalizes the first letter and removes the trailing period from the description 1`] = ` 4 | "# Disallow foo (\`test/no-foo\`) 5 | 6 | 7 | " 8 | `; 9 | 10 | exports[`generate (rule descriptions) no rules with description generates the documentation 1`] = ` 11 | "## Rules 12 | 13 | 14 | | Name | 15 | | :----------------------------- | 16 | | [no-foo](docs/rules/no-foo.md) | 17 | 18 | 19 | " 20 | `; 21 | 22 | exports[`generate (rule descriptions) no rules with description generates the documentation 2`] = ` 23 | "# test/no-foo 24 | 25 | 26 | " 27 | `; 28 | 29 | exports[`generate (rule descriptions) one rule missing description generates the documentation 1`] = ` 30 | "## Rules 31 | 32 | 33 | | Name | Description | 34 | | :----------------------------- | :---------------------- | 35 | | [no-bar](docs/rules/no-bar.md) | | 36 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | 37 | 38 | 39 | " 40 | `; 41 | 42 | exports[`generate (rule descriptions) one rule missing description generates the documentation 2`] = ` 43 | "# Description for no-foo (\`test/no-foo\`) 44 | 45 | 46 | " 47 | `; 48 | 49 | exports[`generate (rule descriptions) one rule missing description generates the documentation 3`] = ` 50 | "# test/no-bar 51 | 52 | 53 | " 54 | `; 55 | 56 | exports[`generate (rule descriptions) rule with long-enough description to require name column wrapping avoidance adds spaces to the name column 1`] = ` 57 | "## Rules 58 | 59 | 60 | | Name   | Description | 61 | | :----------------------------- | :---------------------------------------------------------------------------------- | 62 | | [no-foo](docs/rules/no-foo.md) | over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars | 63 | 64 | 65 | " 66 | `; 67 | 68 | exports[`generate (rule descriptions) rule with long-enough description to require name column wrapping avoidance adds spaces to the name column 2`] = ` 69 | "# Over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars (\`test/no-foo\`) 70 | 71 | 72 | " 73 | `; 74 | 75 | exports[`generate (rule descriptions) rule with long-enough description to require name column wrapping avoidance but rule name too short does not add spaces to name column 1`] = ` 76 | "## Rules 77 | 78 | 79 | | Name | Description | 80 | | :----------------------- | :---------------------------------------------------------------------------------- | 81 | | [foo](docs/rules/foo.md) | over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars | 82 | 83 | 84 | " 85 | `; 86 | 87 | exports[`generate (rule descriptions) rule with long-enough description to require name column wrapping avoidance but rule name too short does not add spaces to name column 2`] = ` 88 | "# Over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars over 60 chars (\`test/foo\`) 89 | 90 | 91 | " 92 | `; 93 | 94 | exports[`generate (rule descriptions) with rule description that needs to be escaped in table generates the documentation 1`] = ` 95 | "## Rules 96 | 97 | 98 | | Name | Description | 99 | | :----------------------------- | :---------- | 100 | | [no-foo](docs/rules/no-foo.md) | Foo\\|Bar | 101 | 102 | 103 | " 104 | `; 105 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-config-emoji-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--config-emoji) basic shows the correct emojis 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💼 Configurations enabled in.\\ 8 | 🔥 Set in the \`recommended\` configuration.\\ 9 | 🎨 Set in the \`stylistic\` configuration. 10 | 11 | | Name | Description | 💼 | 12 | | :----------------------------- | :---------------------- | :------------------------------- | 13 | | [no-bar](docs/rules/no-bar.md) | Description for no-bar. | 🔥 🎨 | 14 | | [no-baz](docs/rules/no-baz.md) | Description for no-boz. | 🎨 ![badge-configWithoutEmoji][] | 15 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | 🔥 | 16 | 17 | 18 | " 19 | `; 20 | 21 | exports[`generate (--config-emoji) basic shows the correct emojis 2`] = ` 22 | "# Description for no-foo (\`test/no-foo\`) 23 | 24 | 💼 This rule is enabled in the 🔥 \`recommended\` config. 25 | 26 | 27 | " 28 | `; 29 | 30 | exports[`generate (--config-emoji) basic shows the correct emojis 3`] = ` 31 | "# Description for no-bar (\`test/no-bar\`) 32 | 33 | 💼 This rule is enabled in the following configs: 🔥 \`recommended\`, 🎨 \`stylistic\`. 34 | 35 | 36 | " 37 | `; 38 | 39 | exports[`generate (--config-emoji) basic shows the correct emojis 4`] = ` 40 | "# Description for no-boz (\`test/no-baz\`) 41 | 42 | 💼 This rule is enabled in the following configs: \`configWithoutEmoji\`, 🎨 \`stylistic\`. 43 | 44 | 45 | " 46 | `; 47 | 48 | exports[`generate (--config-emoji) removing default emoji for a config reverts to using a badge for the config 1`] = ` 49 | "## Rules 50 | 51 | 52 | 💼 Configurations enabled in. 53 | 54 | | Name | Description | 💼 | 55 | | :----------------------------- | :---------------------- | :--------------------- | 56 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ![badge-recommended][] | 57 | 58 | 59 | " 60 | `; 61 | 62 | exports[`generate (--config-emoji) removing default emoji for a config reverts to using a badge for the config 2`] = ` 63 | "# Description for no-foo (\`test/no-foo\`) 64 | 65 | 💼 This rule is enabled in the \`recommended\` config. 66 | 67 | 68 | " 69 | `; 70 | 71 | exports[`generate (--config-emoji) rule with emoji and badge configs sorts emojis before badges 1`] = ` 72 | "## Rules 73 | 74 | 75 | 💼 Configurations enabled in.\\ 76 | 🎨 Set in the \`bar\` configuration.\\ 77 | 🔥 Set in the \`foo\` configuration. 78 | 79 | | Name | Description | 💼 | 80 | | :----------------------------- | :---------------------- | :------------------- | 81 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | 🎨 🔥 ![badge-baz][] | 82 | 83 | 84 | " 85 | `; 86 | 87 | exports[`generate (--config-emoji) rule with emoji and badge configs sorts emojis before badges 2`] = ` 88 | "# Description for no-foo (\`test/no-foo\`) 89 | 90 | 💼 This rule is enabled in the following configs: 🎨 \`bar\`, \`baz\`, 🔥 \`foo\`. 91 | 92 | 93 | " 94 | `; 95 | 96 | exports[`generate (--config-emoji) with one config that does not have emoji shows the default config emoji 1`] = ` 97 | "## Rules 98 | 99 | 100 | 💼 Configurations enabled in. 101 | 102 | | Name | Description | 💼 | 103 | | :----------------------------- | :---------------------- | :---------------------------- | 104 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ![badge-configWithoutEmoji][] | 105 | 106 | 107 | " 108 | `; 109 | 110 | exports[`generate (--config-emoji) with one config that does not have emoji shows the default config emoji 2`] = ` 111 | "# Description for no-foo (\`test/no-foo\`) 112 | 113 | 💼 This rule is enabled in the \`configWithoutEmoji\` config. 114 | 115 | 116 | " 117 | `; 118 | -------------------------------------------------------------------------------- /lib/plugin-configs.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context.js'; 2 | import { SEVERITY_TYPE_TO_SET } from './types.js'; 3 | import type { SEVERITY_TYPE } from './types.js'; 4 | 5 | export function getConfigsThatSetARule( 6 | context: Context, 7 | severityType?: SEVERITY_TYPE, 8 | ) { 9 | const { configsToRules, options, plugin } = context; 10 | const { ignoreConfig } = options; 11 | 12 | /* istanbul ignore next -- this shouldn't happen */ 13 | if (!plugin.rules) { 14 | throw new Error('Missing rules in plugin.'); 15 | } 16 | const ruleNames = Object.keys(plugin.rules); 17 | return ( 18 | Object.entries(configsToRules) 19 | .filter(([configName]) => 20 | // Only consider configs that configure at least one of the plugin's rules. 21 | ruleNames.some((ruleName) => 22 | getConfigsForRule(context, ruleName, severityType).includes( 23 | configName, 24 | ), 25 | ), 26 | ) 27 | // Filter out ignored configs. 28 | .filter(([configName]) => !ignoreConfig.includes(configName)) 29 | .map(([configName]) => configName) 30 | .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) 31 | ); 32 | } 33 | 34 | /** 35 | * Get config names that a given rule belongs to. 36 | * @param severityType - Include configs that set the rule to this severity. Omit to allow any severity. 37 | */ 38 | export function getConfigsForRule( 39 | context: Context, 40 | ruleName: string, 41 | severityType?: SEVERITY_TYPE, 42 | ) { 43 | const { configsToRules, options, pluginPrefix } = context; 44 | const { ignoreConfig } = options; 45 | 46 | const configsToRulesWithoutIgnored = Object.fromEntries( 47 | Object.entries(configsToRules).filter( 48 | ([configName]) => !ignoreConfig.includes(configName), 49 | ), 50 | ); 51 | 52 | const severity = severityType 53 | ? SEVERITY_TYPE_TO_SET[severityType] 54 | : undefined; 55 | const configNames: Array = []; 56 | 57 | for (const configName in configsToRulesWithoutIgnored) { 58 | const rules = configsToRules[configName]; 59 | const value = rules[`${pluginPrefix}/${ruleName}`]; 60 | const isSet = 61 | ((typeof value === 'string' || typeof value === 'number') && 62 | (!severity || severity.has(value))) || 63 | (typeof value === 'object' && 64 | Array.isArray(value) && 65 | value.length > 0 && 66 | (!severity || severity.has(value[0]))); 67 | 68 | if (isSet) { 69 | configNames.push(configName); 70 | } 71 | } 72 | 73 | return configNames.sort((a, b) => 74 | a.toLowerCase().localeCompare(b.toLowerCase()), 75 | ); 76 | } 77 | 78 | /** 79 | * Find the representation of a config to display. 80 | * @param configEmojis - known list of configs and corresponding emojis 81 | * @param configName - name of the config to find an emoji for 82 | * @param options 83 | * @param options.fallback - if true and no emoji is found, choose whether to fallback to a badge. 84 | * @returns the string to display for the config 85 | */ 86 | export function findConfigEmoji( 87 | context: Context, 88 | configName: string, 89 | fallback?: 'badge', 90 | ) { 91 | const { options } = context; 92 | const { configEmojis } = options; 93 | 94 | let emoji = configEmojis.find( 95 | (configEmoji) => configEmoji.config === configName, 96 | )?.emoji; 97 | if (!emoji) { 98 | if (fallback === 'badge') { 99 | emoji = `![badge-${configName}][]`; 100 | } else { 101 | // No fallback. 102 | return undefined; 103 | } 104 | } 105 | 106 | return emoji; 107 | } 108 | 109 | /** 110 | * Get the emojis for the configs that set a rule to a certain severity. 111 | */ 112 | export function getEmojisForConfigsSettingRuleToSeverity( 113 | context: Context, 114 | ruleName: string, 115 | severityType: SEVERITY_TYPE, 116 | ) { 117 | const configsOfThisSeverity = getConfigsForRule( 118 | context, 119 | ruleName, 120 | severityType, 121 | ); 122 | 123 | const emojis: string[] = []; 124 | for (const configName of configsOfThisSeverity) { 125 | // Find the emoji for each config or otherwise use a badge that can be defined in markdown. 126 | const emoji = findConfigEmoji(context, configName, 'badge'); 127 | /* istanbul ignore next -- this shouldn't happen */ 128 | if (typeof emoji !== 'string') { 129 | throw new TypeError('Emoji will always be a string thanks to fallback'); 130 | } 131 | emojis.push(emoji); 132 | } 133 | 134 | return emojis; 135 | } 136 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/general-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (general) basic updates the documentation 1`] = ` 4 | "# eslint-plugin-test 5 | Description. 6 | ## Rules 7 | 8 | 9 | 💼 Configurations enabled in.\\ 10 | 🌐 Set in the \`all\` configuration.\\ 11 | ✅ Set in the \`recommended\` configuration.\\ 12 | 🎨 Set in the \`style\` configuration.\\ 13 | 🔧 Automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\\ 14 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 15 | 16 | | Name | Description | 💼 | 🔧 | 💡 | 17 | | :----------------------------- | :--------------------- | :---- | :- | :- | 18 | | [no-bar](docs/rules/no-bar.md) | Description of no-bar. | 🌐 🎨 | 🔧 | | 19 | | [no-baz](docs/rules/no-baz.md) | Description of no-boz. | | | | 20 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 🌐 ✅ | 🔧 | 💡 | 21 | 22 | 23 | more content." 24 | `; 25 | 26 | exports[`generate (general) basic updates the documentation 2`] = ` 27 | "# Description of no-foo (\`test/no-foo\`) 28 | 29 | 💼 This rule is enabled in the following configs: 🌐 \`all\`, ✅ \`recommended\`. 30 | 31 | 🔧💡 This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 32 | 33 | 34 | ## Rule details 35 | details 36 | ## Options 37 | optionToDoSomething1 - explanation 38 | optionToDoSomething2 - explanation" 39 | `; 40 | 41 | exports[`generate (general) basic updates the documentation 3`] = ` 42 | "# Description of no-bar (\`test/no-bar\`) 43 | 44 | 💼 This rule is enabled in the following configs: 🌐 \`all\`, 🎨 \`style\`. 45 | 46 | 🔧 This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 47 | 48 | 49 | ## Rule details 50 | details" 51 | `; 52 | 53 | exports[`generate (general) basic updates the documentation 4`] = ` 54 | "# Description of no-boz (\`test/no-baz\`) 55 | 56 | 57 | ## Rule details 58 | details" 59 | `; 60 | 61 | exports[`generate (general) plugin prefix uses \`plugin.meta.name\` as source for rule prefix 1`] = ` 62 | "# eslint-plugin-test 63 | Description. 64 | ## Rules 65 | 66 | 67 | 🔧 Automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\\ 68 | 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 69 | 70 | | Name | Description | 🔧 | 💡 | 71 | | :----------------------------- | :--------------------- | :- | :- | 72 | | [no-bar](docs/rules/no-bar.md) | Description of no-bar. | 🔧 | | 73 | | [no-baz](docs/rules/no-baz.md) | Description of no-boz. | | | 74 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 🔧 | 💡 | 75 | 76 | 77 | more content." 78 | `; 79 | 80 | exports[`generate (general) plugin prefix uses \`plugin.meta.name\` as source for rule prefix 2`] = ` 81 | "# Description of no-foo (\`custom/no-foo\`) 82 | 83 | 🔧💡 This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). 84 | 85 | 86 | ## Rule details 87 | details 88 | ## Options 89 | optionToDoSomething1 - explanation 90 | optionToDoSomething2 - explanation" 91 | `; 92 | 93 | exports[`generate (general) plugin prefix uses \`plugin.meta.name\` as source for rule prefix 3`] = ` 94 | "# Description of no-bar (\`custom/no-bar\`) 95 | 96 | 🔧 This rule is automatically fixable by the [\`--fix\` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 97 | 98 | 99 | ## Rule details 100 | details" 101 | `; 102 | 103 | exports[`generate (general) plugin prefix uses \`plugin.meta.name\` as source for rule prefix 4`] = ` 104 | "# Description of no-boz (\`custom/no-baz\`) 105 | 106 | 107 | ## Rule details 108 | details" 109 | `; 110 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/option-url-rule-doc-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (--url-rule-doc) basic uses the right URLs 1`] = ` 4 | "## Rules 5 | 6 | 7 | ❌ Deprecated. 8 | 9 | | Name | Description | ❌ | 10 | | :---------------------------------------------- | :---------------------- | :- | 11 | | [no-bar](https://example.com/rule-docs/no-bar/) | Description for no-bar. | | 12 | | [no-foo](https://example.com/rule-docs/no-foo/) | Description for no-foo. | ❌ | 13 | 14 | 15 | " 16 | `; 17 | 18 | exports[`generate (--url-rule-doc) basic uses the right URLs 2`] = ` 19 | "# Description for no-foo (\`test/no-foo\`) 20 | 21 | ❌ This rule is deprecated. It was replaced by [\`test/no-bar\`](https://example.com/rule-docs/no-bar/). 22 | 23 | 24 | " 25 | `; 26 | 27 | exports[`generate (--url-rule-doc) basic uses the right URLs 3`] = ` 28 | "# Description for no-bar (\`test/no-bar\`) 29 | 30 | 31 | " 32 | `; 33 | 34 | exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 1`] = ` 35 | "## Rules 36 | 37 | 38 | ❌ Deprecated. 39 | 40 | | Name | Description | ❌ | 41 | | :----------------------------- | :---------------------- | :- | 42 | | [no-bar](docs/rules/no-bar.md) | Description for no-bar. | | 43 | | [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ❌ | 44 | 45 | 46 | " 47 | `; 48 | 49 | exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 2`] = ` 50 | "## Rules 51 | 52 | 53 | ❌ Deprecated. 54 | 55 | | Name | Description | ❌ | 56 | | :-------------------------------- | :---------------------- | :- | 57 | | [no-bar](../docs/rules/no-bar.md) | Description for no-bar. | | 58 | | [no-foo](../docs/rules/no-foo.md) | Description for no-foo. | ❌ | 59 | 60 | 61 | " 62 | `; 63 | 64 | exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 3`] = ` 65 | "# Description for no-foo (\`test/no-foo\`) 66 | 67 | ❌ This rule is deprecated. It was replaced by [\`test/no-bar\`](no-bar.md). 68 | 69 | 70 | " 71 | `; 72 | 73 | exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 4`] = ` 74 | "# Description for no-bar (\`test/no-bar\`) 75 | 76 | 77 | " 78 | `; 79 | 80 | exports[`generate (--url-rule-doc) function uses the custom URL 1`] = ` 81 | "## Rules 82 | 83 | 84 | ❌ Deprecated. 85 | 86 | | Name | Description | ❌ | 87 | | :----------------------------------------------------------------- | :---------------------- | :- | 88 | | [no-bar](https://example.com/rule-docs/name:no-bar/path:README.md) | Description for no-bar. | | 89 | | [no-foo](https://example.com/rule-docs/name:no-foo/path:README.md) | Description for no-foo. | ❌ | 90 | 91 | 92 | " 93 | `; 94 | 95 | exports[`generate (--url-rule-doc) function uses the custom URL 2`] = ` 96 | "## Rules 97 | 98 | 99 | ❌ Deprecated. 100 | 101 | | Name | Description | ❌ | 102 | | :------------------------------------------------------------------------ | :---------------------- | :- | 103 | | [no-bar](https://example.com/rule-docs/name:no-bar/path:nested/README.md) | Description for no-bar. | | 104 | | [no-foo](https://example.com/rule-docs/name:no-foo/path:nested/README.md) | Description for no-foo. | ❌ | 105 | 106 | 107 | " 108 | `; 109 | 110 | exports[`generate (--url-rule-doc) function uses the custom URL 3`] = ` 111 | "# Description for no-foo (\`test/no-foo\`) 112 | 113 | ❌ This rule is deprecated. It was replaced by [\`test/no-bar\`](https://example.com/rule-docs/name:no-bar/path:docs/rules/no-foo.md). 114 | 115 | 116 | " 117 | `; 118 | 119 | exports[`generate (--url-rule-doc) function uses the custom URL 4`] = ` 120 | "# Description for no-bar (\`test/no-bar\`) 121 | 122 | 123 | " 124 | `; 125 | -------------------------------------------------------------------------------- /test/lib/generate/option-config-format-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (--config-format)', function () { 13 | describe('name', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 26 | }, 27 | configs: { 28 | recommended: { 29 | rules: { 30 | 'test/no-foo': 'error', 31 | } 32 | } 33 | } 34 | };`, 35 | 36 | 'README.md': '## Rules\n', 37 | 38 | 'docs/rules/no-foo.md': '', 39 | 40 | // Needed for some of the test infrastructure to work. 41 | node_modules: mockFs.load(PATH_NODE_MODULES), 42 | }); 43 | }); 44 | 45 | afterEach(function () { 46 | mockFs.restore(); 47 | jest.resetModules(); 48 | }); 49 | 50 | it('uses the right format', async function () { 51 | await generate('.', { 52 | configFormat: 'name', 53 | }); 54 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 55 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 56 | }); 57 | }); 58 | 59 | describe('plugin-colon-prefix-name', function () { 60 | beforeEach(function () { 61 | mockFs({ 62 | 'package.json': JSON.stringify({ 63 | name: 'eslint-plugin-test', 64 | exports: 'index.js', 65 | type: 'module', 66 | }), 67 | 68 | 'index.js': ` 69 | export default { 70 | rules: { 71 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 72 | }, 73 | configs: { 74 | recommended: { 75 | rules: { 76 | 'test/no-foo': 'error', 77 | } 78 | } 79 | } 80 | };`, 81 | 82 | 'README.md': '## Rules\n', 83 | 84 | 'docs/rules/no-foo.md': '', 85 | 86 | // Needed for some of the test infrastructure to work. 87 | node_modules: mockFs.load(PATH_NODE_MODULES), 88 | }); 89 | }); 90 | 91 | afterEach(function () { 92 | mockFs.restore(); 93 | jest.resetModules(); 94 | }); 95 | 96 | it('uses the right format', async function () { 97 | await generate('.', { 98 | configFormat: 'plugin-colon-prefix-name', 99 | }); 100 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 101 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 102 | }); 103 | }); 104 | 105 | describe('prefix-name', function () { 106 | beforeEach(function () { 107 | mockFs({ 108 | 'package.json': JSON.stringify({ 109 | name: 'eslint-plugin-test', 110 | exports: 'index.js', 111 | type: 'module', 112 | }), 113 | 114 | 'index.js': ` 115 | export default { 116 | rules: { 117 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 118 | }, 119 | configs: { 120 | recommended: { 121 | rules: { 122 | 'test/no-foo': 'error', 123 | } 124 | } 125 | } 126 | };`, 127 | 128 | 'README.md': '## Rules\n', 129 | 130 | 'docs/rules/no-foo.md': '', 131 | 132 | // Needed for some of the test infrastructure to work. 133 | node_modules: mockFs.load(PATH_NODE_MODULES), 134 | }); 135 | }); 136 | 137 | afterEach(function () { 138 | mockFs.restore(); 139 | jest.resetModules(); 140 | }); 141 | 142 | it('uses the right format', async function () { 143 | await generate('.', { 144 | configFormat: 'prefix-name', 145 | }); 146 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 147 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /test/lib/generate/rule-metadata-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (rule metadata)', function () { 13 | describe('deprecated function-style rule', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': function create () {} 26 | }, 27 | configs: { 28 | recommended: { 29 | rules: { 30 | 'test/no-foo': 'error', 31 | } 32 | }, 33 | } 34 | };`, 35 | 36 | 'README.md': '## Rules\n', 37 | 38 | 'docs/rules/no-foo.md': '', 39 | 40 | // Needed for some of the test infrastructure to work. 41 | node_modules: mockFs.load(PATH_NODE_MODULES), 42 | }); 43 | }); 44 | 45 | afterEach(function () { 46 | mockFs.restore(); 47 | jest.resetModules(); 48 | }); 49 | 50 | it('generates the documentation', async function () { 51 | await generate('.'); 52 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 53 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 54 | }); 55 | }); 56 | 57 | describe('deprecated function-style rule with deprecated/schema properties', function () { 58 | beforeEach(function () { 59 | mockFs({ 60 | 'package.json': JSON.stringify({ 61 | name: 'eslint-plugin-test', 62 | exports: 'index.js', 63 | type: 'module', 64 | }), 65 | 66 | 'index.js': ` 67 | const noFoo = function create () {}; 68 | noFoo.deprecated = true; 69 | noFoo.schema = [ 70 | { 71 | type: 'object', 72 | properties: { 73 | optionToDoSomething: { 74 | type: 'boolean', 75 | default: false, 76 | }, 77 | }, 78 | additionalProperties: false, 79 | }, 80 | ]; 81 | export default { 82 | rules: { 83 | 'no-foo': noFoo 84 | }, 85 | };`, 86 | 87 | 'README.md': '## Rules\n', 88 | 89 | 'docs/rules/no-foo.md': '## Options\noptionToDoSomething', 90 | 91 | // Needed for some of the test infrastructure to work. 92 | node_modules: mockFs.load(PATH_NODE_MODULES), 93 | }); 94 | }); 95 | 96 | afterEach(function () { 97 | mockFs.restore(); 98 | jest.resetModules(); 99 | }); 100 | 101 | it('generates the documentation', async function () { 102 | await generate('.', { 103 | // Ensure the relevant properties are shown for the test. 104 | ruleListColumns: ['name', 'deprecated', 'options'], 105 | }); 106 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 107 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 108 | }); 109 | }); 110 | 111 | describe('rule with no meta object', function () { 112 | beforeEach(function () { 113 | mockFs({ 114 | 'package.json': JSON.stringify({ 115 | name: 'eslint-plugin-test', 116 | exports: 'index.js', 117 | type: 'module', 118 | }), 119 | 120 | 'index.js': ` 121 | export default { 122 | rules: { 123 | 'no-foo': { create(context) {} }, 124 | }, 125 | configs: { 126 | recommended: { 127 | rules: { 128 | 'test/no-foo': 'error', 129 | } 130 | }, 131 | } 132 | };`, 133 | 134 | 'README.md': '## Rules\n', 135 | 136 | 'docs/rules/no-foo.md': '', 137 | 138 | // Needed for some of the test infrastructure to work. 139 | node_modules: mockFs.load(PATH_NODE_MODULES), 140 | }); 141 | }); 142 | 143 | afterEach(function () { 144 | mockFs.restore(); 145 | jest.resetModules(); 146 | }); 147 | 148 | it('generates the documentation', async function () { 149 | await generate('.'); 150 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 151 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /lib/option-parsers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING, 3 | NOTICE_TYPE_DEFAULT_PRESENCE_AND_ORDERING, 4 | } from './options.js'; 5 | import { COLUMN_TYPE, NOTICE_TYPE } from './types.js'; 6 | import { 7 | EMOJI_CONFIGS, 8 | EMOJI_CONFIG_ERROR, 9 | RESERVED_EMOJIS, 10 | } from './emojis.js'; 11 | import type { Plugin, ConfigEmojis, GenerateOptions } from './types.js'; 12 | 13 | /** Parse the options, check for errors, and set defaults. */ 14 | export function parseConfigEmojiOptions( 15 | plugin: Plugin, 16 | configEmoji: GenerateOptions['configEmoji'], 17 | ): ConfigEmojis { 18 | const configsSeen = new Set(); 19 | const configsWithDefaultEmojiRemoved: string[] = []; 20 | const configEmojis = 21 | configEmoji === undefined 22 | ? [] 23 | : configEmoji.flatMap((configEmojiItem) => { 24 | const [config, emoji, ...extra] = configEmojiItem as 25 | | typeof configEmojiItem 26 | | [configName: string, emoji: string, extra: string[]]; 27 | 28 | // Check for duplicate configs. 29 | if (configsSeen.has(config)) { 30 | throw new Error( 31 | `Duplicate config name in configEmoji options: ${config}`, 32 | ); 33 | } else { 34 | configsSeen.add(config); 35 | } 36 | 37 | if (config && !emoji && Object.keys(EMOJI_CONFIGS).includes(config)) { 38 | // User wants to remove the default emoji for this config. 39 | configsWithDefaultEmojiRemoved.push(config); 40 | return []; 41 | } 42 | 43 | if (!config || !emoji || extra.length > 0) { 44 | throw new Error( 45 | `Invalid configEmoji option: ${String( 46 | configEmojiItem, 47 | )}. Expected format: config,emoji`, 48 | ); 49 | } 50 | 51 | if (plugin.configs?.[config] === undefined) { 52 | throw new Error( 53 | `Invalid configEmoji option: ${config} config not found.`, 54 | ); 55 | } 56 | 57 | if (RESERVED_EMOJIS.includes(emoji)) { 58 | throw new Error( 59 | `Cannot specify reserved emoji ${EMOJI_CONFIG_ERROR}.`, 60 | ); 61 | } 62 | 63 | return [{ config, emoji }]; 64 | }); 65 | 66 | // Add default emojis for the common configs for which the user hasn't already specified an emoji. 67 | for (const [config, emoji] of Object.entries(EMOJI_CONFIGS)) { 68 | if (configsWithDefaultEmojiRemoved.includes(config)) { 69 | // Skip the default emoji for this config. 70 | continue; 71 | } 72 | if (!configEmojis.some((configEmoji) => configEmoji.config === config)) { 73 | configEmojis.push({ config, emoji }); 74 | } 75 | } 76 | 77 | return configEmojis; 78 | } 79 | 80 | /** Parse the option, check for errors, and set defaults. */ 81 | export function parseRuleListColumnsOption( 82 | ruleListColumns: readonly string[] | undefined, 83 | ): readonly COLUMN_TYPE[] { 84 | const values = [...(ruleListColumns ?? [])]; 85 | const VALUES_OF_TYPE = new Set(Object.values(COLUMN_TYPE).map(String)); 86 | 87 | // Check for invalid. 88 | const invalid = values.find((val) => !VALUES_OF_TYPE.has(val)); 89 | if (invalid) { 90 | throw new Error(`Invalid ruleListColumns option: ${invalid}`); 91 | } 92 | if (values.length !== new Set(values).size) { 93 | throw new Error('Duplicate value detected in ruleListColumns option.'); 94 | } 95 | 96 | if (values.length === 0) { 97 | // Use default presence and ordering. 98 | values.push( 99 | ...Object.entries(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING) 100 | .filter(([_type, enabled]) => enabled) 101 | .map(([type]) => type), 102 | ); 103 | } 104 | 105 | return values as readonly COLUMN_TYPE[]; 106 | } 107 | 108 | /** Parse the option, check for errors, and set defaults. */ 109 | export function parseRuleDocNoticesOption( 110 | ruleDocNotices: readonly string[] | undefined, 111 | ): readonly NOTICE_TYPE[] { 112 | const values = [...(ruleDocNotices ?? [])]; 113 | const VALUES_OF_TYPE = new Set(Object.values(NOTICE_TYPE).map(String)); 114 | 115 | // Check for invalid. 116 | const invalid = values.find((val) => !VALUES_OF_TYPE.has(val)); 117 | if (invalid) { 118 | throw new Error(`Invalid ruleDocNotices option: ${invalid}`); 119 | } 120 | if (values.length !== new Set(values).size) { 121 | throw new Error('Duplicate value detected in ruleDocNotices option.'); 122 | } 123 | 124 | if (values.length === 0) { 125 | // Use default presence and ordering. 126 | values.push( 127 | ...Object.entries(NOTICE_TYPE_DEFAULT_PRESENCE_AND_ORDERING) 128 | .filter(([_type, enabled]) => enabled) 129 | .map(([type]) => type), 130 | ); 131 | } 132 | 133 | return values as readonly NOTICE_TYPE[]; 134 | } 135 | -------------------------------------------------------------------------------- /test/lib/generate/rule-type-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | import { COLUMN_TYPE } from '../../../lib/types.js'; 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | 11 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 12 | 13 | describe('generate (rule type)', function () { 14 | describe('rule with type, type column not enabled', function () { 15 | beforeEach(function () { 16 | mockFs({ 17 | 'package.json': JSON.stringify({ 18 | name: 'eslint-plugin-test', 19 | exports: 'index.js', 20 | type: 'module', 21 | }), 22 | 23 | 'index.js': ` 24 | export default { 25 | rules: { 26 | 'no-foo': { meta: { type: 'problem' }, create(context) {} }, 27 | }, 28 | };`, 29 | 30 | 'README.md': '## Rules\n', 31 | 32 | 'docs/rules/no-foo.md': '', 33 | 34 | // Needed for some of the test infrastructure to work. 35 | node_modules: mockFs.load(PATH_NODE_MODULES), 36 | }); 37 | }); 38 | 39 | afterEach(function () { 40 | mockFs.restore(); 41 | jest.resetModules(); 42 | }); 43 | 44 | it('hides the type column', async function () { 45 | await generate('.'); 46 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 47 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 48 | }); 49 | }); 50 | 51 | describe('rule with type, type column enabled', function () { 52 | beforeEach(function () { 53 | mockFs({ 54 | 'package.json': JSON.stringify({ 55 | name: 'eslint-plugin-test', 56 | exports: 'index.js', 57 | type: 'module', 58 | }), 59 | 60 | 'index.js': ` 61 | export default { 62 | rules: { 63 | 'no-foo': { meta: { type: 'problem' }, create(context) {} }, 64 | 'no-bar': { meta: { type: 'suggestion' }, create(context) {} }, 65 | 'no-biz': { meta: { type: 'layout' }, create(context) {} }, 66 | 'no-boz': { meta: { type: 'unknown' }, create(context) {} }, 67 | 'no-buz': { meta: { /* no type*/ }, create(context) {} }, 68 | }, 69 | };`, 70 | 71 | 'README.md': '## Rules\n', 72 | 73 | 'docs/rules/no-foo.md': '', 74 | 'docs/rules/no-bar.md': '', 75 | 'docs/rules/no-biz.md': '', 76 | 'docs/rules/no-boz.md': '', 77 | 'docs/rules/no-buz.md': '', 78 | 79 | // Needed for some of the test infrastructure to work. 80 | node_modules: mockFs.load(PATH_NODE_MODULES), 81 | }); 82 | }); 83 | 84 | afterEach(function () { 85 | mockFs.restore(); 86 | jest.resetModules(); 87 | }); 88 | 89 | it('displays the type', async function () { 90 | await generate('.', { 91 | ruleListColumns: [COLUMN_TYPE.NAME, COLUMN_TYPE.TYPE], 92 | }); 93 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 94 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 95 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 96 | expect(readFileSync('docs/rules/no-biz.md', 'utf8')).toMatchSnapshot(); 97 | expect(readFileSync('docs/rules/no-boz.md', 'utf8')).toMatchSnapshot(); 98 | expect(readFileSync('docs/rules/no-buz.md', 'utf8')).toMatchSnapshot(); 99 | }); 100 | }); 101 | 102 | describe('rule with type, type column enabled, but only an unknown type', function () { 103 | beforeEach(function () { 104 | mockFs({ 105 | 'package.json': JSON.stringify({ 106 | name: 'eslint-plugin-test', 107 | exports: 'index.js', 108 | type: 'module', 109 | }), 110 | 111 | 'index.js': ` 112 | export default { 113 | rules: { 114 | 'no-foo': { meta: { type: 'unknown' }, create(context) {} }, 115 | }, 116 | };`, 117 | 118 | 'README.md': '## Rules\n', 119 | 120 | 'docs/rules/no-foo.md': '', 121 | 122 | // Needed for some of the test infrastructure to work. 123 | node_modules: mockFs.load(PATH_NODE_MODULES), 124 | }); 125 | }); 126 | 127 | afterEach(function () { 128 | mockFs.restore(); 129 | jest.resetModules(); 130 | }); 131 | 132 | it('hides the type column and notice', async function () { 133 | await generate('.', { 134 | ruleListColumns: [COLUMN_TYPE.NAME, COLUMN_TYPE.TYPE], 135 | }); 136 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 137 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /lib/rule-list-columns.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EMOJI_DEPRECATED, 3 | EMOJI_FIXABLE, 4 | EMOJI_HAS_SUGGESTIONS, 5 | EMOJI_REQUIRES_TYPE_CHECKING, 6 | EMOJI_TYPE, 7 | EMOJI_CONFIG_FROM_SEVERITY, 8 | EMOJI_OPTIONS, 9 | } from './emojis.js'; 10 | import { RULE_TYPES } from './rule-type.js'; 11 | import { COLUMN_TYPE, SEVERITY_TYPE } from './types.js'; 12 | import { getConfigsThatSetARule } from './plugin-configs.js'; 13 | import { hasOptions } from './rule-options.js'; 14 | import type { RuleNamesAndRules } from './types.js'; 15 | import { Context } from './context.js'; 16 | 17 | /** 18 | * An object containing the column header for each column (as a string or function to generate the string). 19 | */ 20 | export const COLUMN_HEADER: { 21 | [key in COLUMN_TYPE]: 22 | | string 23 | | ((data: { ruleNamesAndRules: RuleNamesAndRules }) => string); 24 | } = { 25 | [COLUMN_TYPE.NAME]: ({ ruleNamesAndRules }) => { 26 | const ruleNames = ruleNamesAndRules.map(([name]) => name); 27 | const longestRuleNameLength = Math.max( 28 | ...ruleNames.map(({ length }) => length), 29 | ); 30 | const ruleDescriptions = ruleNamesAndRules.map( 31 | ([, rule]) => rule.meta?.docs?.description, 32 | ); 33 | const longestRuleDescriptionLength = Math.max( 34 | ...ruleDescriptions.map((description) => 35 | description ? description.length : 0, 36 | ), 37 | ); 38 | 39 | const title = 'Name'; 40 | 41 | // Add nbsp spaces to prevent rule names from wrapping to multiple lines. 42 | // Generally only needed when long descriptions are present causing the name column to wrap. 43 | const spaces = 44 | ruleNames.length > 0 && 45 | longestRuleDescriptionLength >= 60 && 46 | longestRuleNameLength > title.length 47 | ? ' '.repeat(longestRuleNameLength - title.length) // U+00A0 nbsp character. 48 | : ''; 49 | 50 | return `${title}${spaces}`; 51 | }, 52 | 53 | // Simple strings. 54 | [COLUMN_TYPE.CONFIGS_ERROR]: EMOJI_CONFIG_FROM_SEVERITY[SEVERITY_TYPE.error], 55 | [COLUMN_TYPE.CONFIGS_OFF]: EMOJI_CONFIG_FROM_SEVERITY[SEVERITY_TYPE.off], 56 | [COLUMN_TYPE.CONFIGS_WARN]: EMOJI_CONFIG_FROM_SEVERITY[SEVERITY_TYPE.warn], 57 | [COLUMN_TYPE.DEPRECATED]: EMOJI_DEPRECATED, 58 | [COLUMN_TYPE.DESCRIPTION]: 'Description', 59 | [COLUMN_TYPE.FIXABLE]: EMOJI_FIXABLE, 60 | [COLUMN_TYPE.FIXABLE_AND_HAS_SUGGESTIONS]: `${EMOJI_FIXABLE}${EMOJI_HAS_SUGGESTIONS}`, 61 | [COLUMN_TYPE.HAS_SUGGESTIONS]: EMOJI_HAS_SUGGESTIONS, 62 | [COLUMN_TYPE.OPTIONS]: EMOJI_OPTIONS, 63 | [COLUMN_TYPE.REQUIRES_TYPE_CHECKING]: EMOJI_REQUIRES_TYPE_CHECKING, 64 | [COLUMN_TYPE.TYPE]: EMOJI_TYPE, 65 | }; 66 | 67 | /** 68 | * Decide what columns to display for the rules list. 69 | * Only display columns for which there is at least one rule that has a value for that column. 70 | */ 71 | export function getColumns( 72 | context: Context, 73 | ruleNamesAndRules: RuleNamesAndRules, 74 | ): Record { 75 | const { options } = context; 76 | const { ruleListColumns } = options; 77 | 78 | const columns: { 79 | [key in COLUMN_TYPE]: boolean; 80 | } = { 81 | // Alphabetical order. 82 | [COLUMN_TYPE.CONFIGS_ERROR]: 83 | getConfigsThatSetARule(context, SEVERITY_TYPE.error).length > 0, 84 | [COLUMN_TYPE.CONFIGS_OFF]: 85 | getConfigsThatSetARule(context, SEVERITY_TYPE.off).length > 0, 86 | [COLUMN_TYPE.CONFIGS_WARN]: 87 | getConfigsThatSetARule(context, SEVERITY_TYPE.warn).length > 0, 88 | [COLUMN_TYPE.DEPRECATED]: ruleNamesAndRules.some( 89 | ([, rule]) => rule.meta?.deprecated, 90 | ), 91 | [COLUMN_TYPE.DESCRIPTION]: ruleNamesAndRules.some( 92 | ([, rule]) => rule.meta?.docs?.description, 93 | ), 94 | [COLUMN_TYPE.FIXABLE]: ruleNamesAndRules.some( 95 | ([, rule]) => rule.meta?.fixable, 96 | ), 97 | [COLUMN_TYPE.FIXABLE_AND_HAS_SUGGESTIONS]: ruleNamesAndRules.some( 98 | ([, rule]) => rule.meta?.fixable || rule.meta?.hasSuggestions, 99 | ), 100 | [COLUMN_TYPE.HAS_SUGGESTIONS]: ruleNamesAndRules.some( 101 | ([, rule]) => rule.meta?.hasSuggestions, 102 | ), 103 | [COLUMN_TYPE.NAME]: true, 104 | [COLUMN_TYPE.OPTIONS]: ruleNamesAndRules.some(([, rule]) => 105 | hasOptions(rule.meta?.schema), 106 | ), 107 | [COLUMN_TYPE.REQUIRES_TYPE_CHECKING]: ruleNamesAndRules.some( 108 | // @ts-expect-error -- TODO: requiresTypeChecking type not present 109 | ([, rule]) => rule.meta?.docs?.requiresTypeChecking, 110 | ), 111 | // Show type column only if we found at least one rule with a standard type. 112 | [COLUMN_TYPE.TYPE]: ruleNamesAndRules.some( 113 | ([, rule]) => rule.meta?.type && RULE_TYPES.includes(rule.meta?.type), 114 | ), 115 | }; 116 | 117 | // Recreate object using the ordering and presence of columns specified in ruleListColumns. 118 | return Object.fromEntries( 119 | ruleListColumns.map((type) => [type, columns[type]]), 120 | ) as Record; 121 | } 122 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/eol-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using crlf end of line from ".editorconfig" 1`] = ` 4 | "## Rules 5 | 6 | 7 | 💼 Configurations enabled in. 8 | 9 | | Name | 💼 | 10 | | :------------------- | :------------------------------------- | 11 | | [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | 12 | | [B](docs/rules/B.md) | | 13 | | [c](docs/rules/c.md) | | 14 | 15 | 16 | " 17 | `; 18 | 19 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using crlf end of line from ".editorconfig" 2`] = ` 20 | "# test/a 21 | 22 | 💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. 23 | 24 | 25 | " 26 | `; 27 | 28 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using crlf end of line from ".editorconfig" 3`] = ` 29 | "# test/B 30 | 31 | 32 | " 33 | `; 34 | 35 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using crlf end of line from ".editorconfig" 4`] = ` 36 | "# test/c 37 | 38 | 39 | " 40 | `; 41 | 42 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using lf end of line from ".editorconfig" 1`] = ` 43 | "## Rules 44 | 45 | 46 | 💼 Configurations enabled in. 47 | 48 | | Name | 💼 | 49 | | :------------------- | :------------------------------------- | 50 | | [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | 51 | | [B](docs/rules/B.md) | | 52 | | [c](docs/rules/c.md) | | 53 | 54 | 55 | " 56 | `; 57 | 58 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using lf end of line from ".editorconfig" 2`] = ` 59 | "# test/a 60 | 61 | 💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. 62 | 63 | 64 | " 65 | `; 66 | 67 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using lf end of line from ".editorconfig" 3`] = ` 68 | "# test/B 69 | 70 | 71 | " 72 | `; 73 | 74 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using lf end of line from ".editorconfig" 4`] = ` 75 | "# test/c 76 | 77 | 78 | " 79 | `; 80 | 81 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using the end of line from ".editorconfig" while respecting the .md specific end of line setting 1`] = ` 82 | "## Rules 83 | 84 | 85 | 💼 Configurations enabled in. 86 | 87 | | Name | 💼 | 88 | | :------------------- | :------------------------------------- | 89 | | [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | 90 | | [B](docs/rules/B.md) | | 91 | | [c](docs/rules/c.md) | | 92 | 93 | 94 | " 95 | `; 96 | 97 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using the end of line from ".editorconfig" while respecting the .md specific end of line setting 2`] = ` 98 | "# test/a 99 | 100 | 💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. 101 | 102 | 103 | " 104 | `; 105 | 106 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using the end of line from ".editorconfig" while respecting the .md specific end of line setting 3`] = ` 107 | "# test/B 108 | 109 | 110 | " 111 | `; 112 | 113 | exports[`getEndOfLine with a ".editorconfig" file generates using the correct end of line when ".editorconfig" exists generates using the end of line from ".editorconfig" while respecting the .md specific end of line setting 4`] = ` 114 | "# test/c 115 | 116 | 117 | " 118 | `; 119 | -------------------------------------------------------------------------------- /lib/markdown.ts: -------------------------------------------------------------------------------- 1 | // General helpers for dealing with markdown files / content. 2 | 3 | import { Context } from './context.js'; 4 | 5 | /** 6 | * Replace the header of a doc up to and including the specified marker. 7 | * Insert at beginning if header doesn't exist. 8 | * @param markdown - doc content 9 | * @param newHeader - new header including marker 10 | * @param marker - marker to indicate end of header 11 | */ 12 | export function replaceOrCreateHeader( 13 | context: Context, 14 | markdown: string, 15 | newHeader: string, 16 | marker: string, 17 | ) { 18 | const { endOfLine } = context; 19 | 20 | const lines = markdown.split(endOfLine); 21 | 22 | const titleLineIndex = lines.findIndex((line) => line.startsWith('# ')); 23 | const markerLineIndex = lines.indexOf(marker); 24 | const dashesLineIndex1 = lines.indexOf('---'); 25 | const dashesLineIndex2 = lines.indexOf('---', dashesLineIndex1 + 1); 26 | 27 | // Any YAML front matter or anything else above the title should be kept as-is ahead of the new header. 28 | const preHeader = lines 29 | .slice(0, Math.max(titleLineIndex, dashesLineIndex2 + 1)) 30 | .join(endOfLine); 31 | 32 | // Anything after the marker comment, title, or YAML front matter should be kept as-is after the new header. 33 | const postHeader = lines 34 | .slice( 35 | Math.max(markerLineIndex + 1, titleLineIndex + 1, dashesLineIndex2 + 1), 36 | ) 37 | .join(endOfLine); 38 | 39 | return `${ 40 | preHeader ? `${preHeader}${endOfLine}` : '' 41 | }${newHeader}${endOfLine}${postHeader}`; 42 | } 43 | 44 | /** 45 | * Find the section most likely to be the top-level section for a given string. 46 | */ 47 | export function findSectionHeader( 48 | context: Context, 49 | markdown: string, 50 | str: string, 51 | ): string | undefined { 52 | const { endOfLine } = context; 53 | 54 | // Get all the matching strings. 55 | const regexp = new RegExp(`## .*${str}.*${endOfLine}`, 'giu'); 56 | const sectionPotentialMatches = [...markdown.matchAll(regexp)].map( 57 | (match) => match[0], 58 | ); 59 | 60 | if (sectionPotentialMatches.length === 0) { 61 | // No section found. 62 | return undefined; 63 | } 64 | 65 | if (sectionPotentialMatches.length === 1) { 66 | // If there's only one match, we can assume it's the section. 67 | return sectionPotentialMatches[0]; 68 | } 69 | 70 | // Otherwise assume the shortest match is the correct one. 71 | return sectionPotentialMatches.sort( 72 | (a: string, b: string) => a.length - b.length, 73 | )[0]; 74 | } 75 | 76 | export function findFinalHeaderLevel(context: Context, str: string) { 77 | const { endOfLine } = context; 78 | 79 | const lines = str.split(endOfLine); 80 | const finalHeader = lines.reverse().find((line) => line.match('^(#+) .+$')); 81 | return finalHeader ? finalHeader.indexOf(' ') : undefined; 82 | } 83 | 84 | /** 85 | * Ensure a doc contains (or doesn't contain) some particular content. 86 | * Upon failure, output the failure and set a failure exit code. 87 | * @param docName - name of doc for error message 88 | * @param contentName - name of content for error message 89 | * @param contents - the doc's contents 90 | * @param content - the content we are checking for 91 | * @param expected - whether the content should be present or not present 92 | */ 93 | export function expectContentOrFail( 94 | docName: string, 95 | contentName: string, 96 | contents: string, 97 | content: string, 98 | expected: boolean, 99 | ) { 100 | // Check for the content and also the versions of the content with escaped quotes 101 | // in case escaping is needed where the content is referenced. 102 | const hasContent = 103 | contents.includes(content) || 104 | contents.includes(content.replaceAll('"', String.raw`\"`)) || 105 | contents.includes(content.replaceAll("'", String.raw`\'`)); 106 | if (hasContent !== expected) { 107 | console.error( 108 | `${docName} should ${ 109 | /* istanbul ignore next -- TODO: test !expected or remove parameter */ 110 | expected ? '' : 'not ' 111 | }have included ${contentName}: ${content}`, 112 | ); 113 | process.exitCode = 1; 114 | } 115 | } 116 | 117 | export function expectSectionHeaderOrFail( 118 | context: Context, 119 | contentName: string, 120 | contents: string, 121 | possibleHeaders: readonly string[], 122 | expected: boolean, 123 | ) { 124 | const found = possibleHeaders.some((header) => 125 | findSectionHeader(context, contents, header), 126 | ); 127 | if (found !== expected) { 128 | if (possibleHeaders.length > 1) { 129 | console.error( 130 | `${contentName} should ${expected ? '' : 'not '}have included ${ 131 | expected ? 'one' : 'any' 132 | } of these headers: ${possibleHeaders.join(', ')}`, 133 | ); 134 | } else { 135 | console.error( 136 | `${contentName} should ${ 137 | expected ? '' : 'not ' 138 | }have included the header: ${possibleHeaders.join(', ')}`, 139 | ); 140 | } 141 | 142 | process.exitCode = 1; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /test/lib/generate/option-rule-doc-notices-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | import { NOTICE_TYPE } from '../../../lib/types.js'; 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | 11 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 12 | 13 | describe('generate (--rule-doc-notices)', function () { 14 | describe('basic', function () { 15 | beforeEach(function () { 16 | mockFs({ 17 | 'package.json': JSON.stringify({ 18 | name: 'eslint-plugin-test', 19 | exports: 'index.js', 20 | type: 'module', 21 | }), 22 | 23 | 'index.js': ` 24 | export default { 25 | rules: { 26 | 'no-foo': { 27 | meta: { 28 | docs: { description: 'Description for no-foo.' }, 29 | hasSuggestions: true, 30 | fixable: 'code', 31 | deprecated: true, 32 | type: 'problem' 33 | }, 34 | create(context) {} 35 | }, 36 | }, 37 | };`, 38 | 39 | 'README.md': '## Rules\n', 40 | 41 | 'docs/rules/no-foo.md': '', 42 | 43 | // Needed for some of the test infrastructure to work. 44 | node_modules: mockFs.load(PATH_NODE_MODULES), 45 | }); 46 | }); 47 | 48 | afterEach(function () { 49 | mockFs.restore(); 50 | jest.resetModules(); 51 | }); 52 | 53 | it('shows the right rule doc notices', async function () { 54 | await generate('.', { 55 | ruleDocNotices: [ 56 | NOTICE_TYPE.HAS_SUGGESTIONS, 57 | NOTICE_TYPE.FIXABLE, 58 | NOTICE_TYPE.DEPRECATED, 59 | NOTICE_TYPE.DESCRIPTION, 60 | NOTICE_TYPE.TYPE, 61 | ], // Random values including all the optional notices. 62 | }); 63 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 64 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 65 | }); 66 | }); 67 | 68 | describe('non-existent notice', function () { 69 | beforeEach(function () { 70 | mockFs({ 71 | 'package.json': JSON.stringify({ 72 | name: 'eslint-plugin-test', 73 | exports: 'index.js', 74 | type: 'module', 75 | }), 76 | 77 | 'index.js': ` 78 | export default { 79 | rules: { 80 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 81 | }, 82 | };`, 83 | 84 | 'README.md': '## Rules\n', 85 | 86 | 'docs/rules/no-foo.md': '', 87 | 88 | // Needed for some of the test infrastructure to work. 89 | node_modules: mockFs.load(PATH_NODE_MODULES), 90 | }); 91 | }); 92 | 93 | afterEach(function () { 94 | mockFs.restore(); 95 | jest.resetModules(); 96 | }); 97 | 98 | it('throws an error', async function () { 99 | await expect( 100 | generate('.', { 101 | // @ts-expect-error -- testing non-existent notice type 102 | ruleDocNotices: [NOTICE_TYPE.FIXABLE, 'non-existent'], 103 | }), 104 | ).rejects.toThrow('Invalid ruleDocNotices option: non-existent'); 105 | }); 106 | }); 107 | 108 | describe('duplicate notice', function () { 109 | beforeEach(function () { 110 | mockFs({ 111 | 'package.json': JSON.stringify({ 112 | name: 'eslint-plugin-test', 113 | exports: 'index.js', 114 | type: 'module', 115 | }), 116 | 117 | 'index.js': ` 118 | export default { 119 | rules: { 120 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 121 | }, 122 | };`, 123 | 124 | 'README.md': '## Rules\n', 125 | 126 | 'docs/rules/no-foo.md': '', 127 | 128 | // Needed for some of the test infrastructure to work. 129 | node_modules: mockFs.load(PATH_NODE_MODULES), 130 | }); 131 | }); 132 | 133 | afterEach(function () { 134 | mockFs.restore(); 135 | jest.resetModules(); 136 | }); 137 | 138 | it('throws an error', async function () { 139 | await expect( 140 | generate('.', { 141 | ruleDocNotices: [NOTICE_TYPE.FIXABLE, NOTICE_TYPE.FIXABLE], 142 | }), 143 | ).rejects.toThrow('Duplicate value detected in ruleDocNotices option.'); 144 | }); 145 | }); 146 | 147 | describe('passing string instead of enum to simulate real-world usage where enum type is not available', function () { 148 | beforeEach(function () { 149 | mockFs({ 150 | 'package.json': JSON.stringify({ 151 | name: 'eslint-plugin-test', 152 | exports: 'index.js', 153 | type: 'module', 154 | }), 155 | 156 | 'index.js': ` 157 | export default { 158 | rules: { 159 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 160 | }, 161 | };`, 162 | 163 | 'README.md': '## Rules\n', 164 | 165 | 'docs/rules/no-foo.md': '## Examples\n', 166 | 167 | // Needed for some of the test infrastructure to work. 168 | node_modules: mockFs.load(PATH_NODE_MODULES), 169 | }); 170 | }); 171 | 172 | afterEach(function () { 173 | mockFs.restore(); 174 | jest.resetModules(); 175 | }); 176 | 177 | it('has no issues', async function () { 178 | await expect( 179 | generate('.', { 180 | ruleDocNotices: ['type'], 181 | ruleListColumns: ['name'], 182 | }), 183 | ).resolves.toBeUndefined(); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /lib/rule-options-list.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BEGIN_RULE_OPTIONS_LIST_MARKER, 3 | END_RULE_OPTIONS_LIST_MARKER, 4 | } from './comment-markers.js'; 5 | import { markdownTable } from 'markdown-table'; 6 | import type { RuleModule } from './types.js'; 7 | import { RuleOption, getAllNamedOptions } from './rule-options.js'; 8 | import { sanitizeMarkdownTable } from './string.js'; 9 | import { Context } from './context.js'; 10 | 11 | export enum COLUMN_TYPE { 12 | // Alphabetical order. 13 | DEFAULT = 'default', 14 | DEPRECATED = 'deprecated', 15 | DESCRIPTION = 'description', 16 | ENUM = 'enum', 17 | NAME = 'name', 18 | REQUIRED = 'required', 19 | TYPE = 'type', 20 | } 21 | 22 | const HEADERS: { 23 | [key in COLUMN_TYPE]: string; 24 | } = { 25 | // Alphabetical order. 26 | [COLUMN_TYPE.DEFAULT]: 'Default', 27 | [COLUMN_TYPE.DEPRECATED]: 'Deprecated', 28 | [COLUMN_TYPE.DESCRIPTION]: 'Description', 29 | [COLUMN_TYPE.ENUM]: 'Choices', 30 | [COLUMN_TYPE.NAME]: 'Name', 31 | [COLUMN_TYPE.REQUIRED]: 'Required', 32 | [COLUMN_TYPE.TYPE]: 'Type', 33 | }; 34 | 35 | const COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING: { 36 | [key in COLUMN_TYPE]: boolean; 37 | } = { 38 | // Object keys ordered in display order. 39 | // Object values indicate whether the column is displayed by default. 40 | [COLUMN_TYPE.NAME]: true, 41 | [COLUMN_TYPE.DESCRIPTION]: true, 42 | [COLUMN_TYPE.TYPE]: true, 43 | [COLUMN_TYPE.ENUM]: true, 44 | [COLUMN_TYPE.DEFAULT]: true, 45 | [COLUMN_TYPE.REQUIRED]: true, 46 | [COLUMN_TYPE.DEPRECATED]: true, 47 | }; 48 | 49 | /** 50 | * Output could look like: 51 | * - `[]` 52 | * - [`hello world`, `1`, `2`, `true`] 53 | */ 54 | function arrayToString(arr: readonly unknown[]): string { 55 | return `${arr.length === 0 ? '`' : ''}[${arr.length > 0 ? '`' : ''}${arr.join( 56 | '`, `', 57 | )}${arr.length > 0 ? '`' : ''}]${arr.length === 0 ? '`' : ''}`; 58 | } 59 | 60 | function ruleOptionToColumnValues(ruleOption: RuleOption): { 61 | [key in COLUMN_TYPE]: string | undefined; 62 | } { 63 | const columns: { 64 | [key in COLUMN_TYPE]: string | undefined; 65 | } = { 66 | // Alphabetical order. 67 | [COLUMN_TYPE.DEFAULT]: 68 | ruleOption.default === undefined 69 | ? undefined 70 | : Array.isArray(ruleOption.default) 71 | ? arrayToString(ruleOption.default) 72 | : `\`${String(ruleOption.default)}\``, // eslint-disable-line @typescript-eslint/no-base-to-string 73 | [COLUMN_TYPE.DEPRECATED]: ruleOption.deprecated ? 'Yes' : undefined, 74 | [COLUMN_TYPE.DESCRIPTION]: ruleOption.description, 75 | [COLUMN_TYPE.ENUM]: 76 | ruleOption.enum && ruleOption.enum.length > 0 77 | ? `\`${ruleOption.enum.join('`, `')}\`` // eslint-disable-line @typescript-eslint/no-base-to-string 78 | : undefined, 79 | [COLUMN_TYPE.NAME]: `\`${ruleOption.name}\``, 80 | [COLUMN_TYPE.REQUIRED]: ruleOption.required ? 'Yes' : undefined, 81 | [COLUMN_TYPE.TYPE]: ruleOption.type || undefined, 82 | }; 83 | 84 | return columns; 85 | } 86 | 87 | function ruleOptionsToColumnsToDisplay(ruleOptions: readonly RuleOption[]): { 88 | [key in COLUMN_TYPE]: boolean; 89 | } { 90 | const columnsToDisplay: { 91 | [key in COLUMN_TYPE]: boolean; 92 | } = { 93 | // Alphabetical order. 94 | [COLUMN_TYPE.DEFAULT]: ruleOptions.some( 95 | (ruleOption) => ruleOption.default !== undefined, 96 | ), 97 | [COLUMN_TYPE.DEPRECATED]: ruleOptions.some( 98 | (ruleOption) => ruleOption.deprecated, 99 | ), 100 | [COLUMN_TYPE.DESCRIPTION]: ruleOptions.some( 101 | (ruleOption) => ruleOption.description, 102 | ), 103 | [COLUMN_TYPE.ENUM]: ruleOptions.some((ruleOption) => ruleOption.enum), 104 | [COLUMN_TYPE.NAME]: true, 105 | [COLUMN_TYPE.REQUIRED]: ruleOptions.some( 106 | (ruleOption) => ruleOption.required, 107 | ), 108 | [COLUMN_TYPE.TYPE]: ruleOptions.some((ruleOption) => ruleOption.type), 109 | }; 110 | return columnsToDisplay; 111 | } 112 | 113 | function generateRuleOptionsListMarkdown( 114 | context: Context, 115 | rule: RuleModule, 116 | ): string { 117 | const ruleOptions = getAllNamedOptions(rule.meta?.schema); 118 | 119 | if (ruleOptions.length === 0) { 120 | return ''; 121 | } 122 | 123 | const columnsToDisplay = ruleOptionsToColumnsToDisplay(ruleOptions); 124 | const listHeaderRow = Object.keys(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING) 125 | .filter((type) => columnsToDisplay[type as COLUMN_TYPE]) 126 | .map((type) => HEADERS[type as COLUMN_TYPE]); 127 | 128 | const rows = [...ruleOptions] 129 | .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) 130 | .map((ruleOption) => { 131 | const ruleOptionColumnValues = ruleOptionToColumnValues(ruleOption); 132 | 133 | // Recreate object using correct ordering and presence of columns. 134 | return Object.keys(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING) 135 | .filter((type) => columnsToDisplay[type as COLUMN_TYPE]) 136 | .map((type) => ruleOptionColumnValues[type as COLUMN_TYPE] || ''); 137 | }); 138 | 139 | return markdownTable( 140 | sanitizeMarkdownTable(context, [listHeaderRow, ...rows]), 141 | { align: 'l' }, // Left-align headers. 142 | ); 143 | } 144 | 145 | export function updateRuleOptionsList( 146 | context: Context, 147 | markdown: string, 148 | rule: RuleModule, 149 | ): string { 150 | const { endOfLine } = context; 151 | 152 | const listStartIndex = markdown.indexOf(BEGIN_RULE_OPTIONS_LIST_MARKER); 153 | let listEndIndex = markdown.indexOf(END_RULE_OPTIONS_LIST_MARKER); 154 | 155 | if (listStartIndex === -1 || listEndIndex === -1) { 156 | // No rule options list found. 157 | return markdown; 158 | } 159 | 160 | // Account for length of pre-existing marker. 161 | listEndIndex += END_RULE_OPTIONS_LIST_MARKER.length; 162 | 163 | const preList = markdown.slice(0, Math.max(0, listStartIndex)); 164 | const postList = markdown.slice(Math.max(0, listEndIndex)); 165 | 166 | // New rule options list. 167 | const list = generateRuleOptionsListMarkdown(context, rule); 168 | 169 | return `${preList}${BEGIN_RULE_OPTIONS_LIST_MARKER}${endOfLine}${endOfLine}${list}${endOfLine}${endOfLine}${END_RULE_OPTIONS_LIST_MARKER}${postList}`; 170 | } 171 | -------------------------------------------------------------------------------- /test/lib/generate/option-url-rule-doc-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (--url-rule-doc)', function () { 13 | describe('basic', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': { 26 | meta: { 27 | docs: { description: 'Description for no-foo.' }, 28 | deprecated: true, 29 | replacedBy: ['no-bar'] 30 | }, 31 | create(context) {} 32 | }, 33 | 'no-bar': { 34 | meta: { 35 | docs: { description: 'Description for no-bar.' } 36 | }, 37 | create(context) {} 38 | }, 39 | }, 40 | };`, 41 | 42 | 'README.md': '## Rules\n', 43 | 44 | 'docs/rules/no-foo.md': '', 45 | 'docs/rules/no-bar.md': '', 46 | 47 | // Needed for some of the test infrastructure to work. 48 | node_modules: mockFs.load(PATH_NODE_MODULES), 49 | }); 50 | }); 51 | 52 | afterEach(function () { 53 | mockFs.restore(); 54 | jest.resetModules(); 55 | }); 56 | 57 | it('uses the right URLs', async function () { 58 | await generate('.', { 59 | urlRuleDoc: 'https://example.com/rule-docs/{name}/', 60 | }); 61 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 62 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 63 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 64 | }); 65 | }); 66 | 67 | describe('function', function () { 68 | beforeEach(function () { 69 | mockFs({ 70 | 'package.json': JSON.stringify({ 71 | name: 'eslint-plugin-test', 72 | exports: 'index.js', 73 | type: 'module', 74 | }), 75 | 76 | 'index.js': ` 77 | export default { 78 | rules: { 79 | 'no-foo': { 80 | meta: { 81 | docs: { description: 'Description for no-foo.' }, 82 | deprecated: true, 83 | replacedBy: ['no-bar'] 84 | }, 85 | create(context) {} 86 | }, 87 | 'no-bar': { 88 | meta: { 89 | docs: { description: 'Description for no-bar.' } 90 | }, 91 | create(context) {} 92 | }, 93 | }, 94 | };`, 95 | 96 | 'README.md': '## Rules\n', 97 | 'nested/README.md': '## Rules\n', 98 | 99 | 'docs/rules/no-foo.md': '', 100 | 'docs/rules/no-bar.md': '', 101 | 102 | // Needed for some of the test infrastructure to work. 103 | node_modules: mockFs.load(PATH_NODE_MODULES), 104 | }); 105 | }); 106 | 107 | afterEach(function () { 108 | mockFs.restore(); 109 | jest.resetModules(); 110 | }); 111 | 112 | it('uses the custom URL', async function () { 113 | await generate('.', { 114 | pathRuleList: ['README.md', 'nested/README.md'], 115 | urlRuleDoc(name, path) { 116 | return `https://example.com/rule-docs/name:${name}/path:${path}`; 117 | }, 118 | }); 119 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 120 | expect(readFileSync('nested/README.md', 'utf8')).toMatchSnapshot(); 121 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 122 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 123 | }); 124 | }); 125 | 126 | describe('function returns undefined', function () { 127 | beforeEach(function () { 128 | mockFs({ 129 | 'package.json': JSON.stringify({ 130 | name: 'eslint-plugin-test', 131 | exports: 'index.js', 132 | type: 'module', 133 | }), 134 | 135 | 'index.js': ` 136 | export default { 137 | rules: { 138 | 'no-foo': { 139 | meta: { 140 | docs: { description: 'Description for no-foo.' }, 141 | deprecated: true, 142 | replacedBy: ['no-bar'] 143 | }, 144 | create(context) {} 145 | }, 146 | 'no-bar': { 147 | meta: { 148 | docs: { description: 'Description for no-bar.' } 149 | }, 150 | create(context) {} 151 | }, 152 | }, 153 | };`, 154 | 155 | 'README.md': '## Rules\n', 156 | 'nested/README.md': '## Rules\n', 157 | 158 | 'docs/rules/no-foo.md': '', 159 | 'docs/rules/no-bar.md': '', 160 | 161 | // Needed for some of the test infrastructure to work. 162 | node_modules: mockFs.load(PATH_NODE_MODULES), 163 | }); 164 | }); 165 | 166 | afterEach(function () { 167 | mockFs.restore(); 168 | jest.resetModules(); 169 | }); 170 | 171 | it('should fallback to the normal URL', async function () { 172 | await generate('.', { 173 | pathRuleList: ['README.md', 'nested/README.md'], 174 | urlRuleDoc() { 175 | return undefined; 176 | }, 177 | }); 178 | expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); 179 | expect(readFileSync('nested/README.md', 'utf8')).toMatchSnapshot(); 180 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 181 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import js from '@eslint/js'; 4 | import eslintPluginN from 'eslint-plugin-n'; 5 | import eslintPluginJest from 'eslint-plugin-jest'; 6 | import eslintPluginUnicorn from 'eslint-plugin-unicorn'; 7 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; // eslint-disable-line import/extensions -- false positive 8 | import * as eslintPluginImport from 'eslint-plugin-import'; 9 | import tseslint from 'typescript-eslint'; 10 | 11 | export default tseslint.config( 12 | // Configs: 13 | js.configs.recommended, 14 | // @ts-expect-error This config is not properly typed. In the future, we might want to use 15 | // `eslint-plugin-import-x` instead of `eslint-pluginimport`, as it is better maintained. 16 | eslintPluginImport.flatConfigs.typescript, 17 | eslintPluginJest.configs['flat/recommended'], 18 | eslintPluginN.configs['flat/recommended'], 19 | eslintPluginPrettierRecommended, 20 | eslintPluginUnicorn.configs['flat/recommended'], 21 | ...tseslint.configs.strictTypeChecked, 22 | 23 | // Individual rules: 24 | { 25 | rules: { 26 | 'n/no-missing-import': 'off', // bug with recognizing node: prefix https://github.com/mysticatea/eslint-plugin-node/issues/275 27 | 28 | 'prettier/prettier': 'error', // see `.prettierrc.json` for format config 29 | 30 | // unicorn rules: 31 | 'unicorn/expiring-todo-comments': 'off', 32 | 'unicorn/import-style': 'off', 33 | 'unicorn/no-anonymous-default-export': 'off', 34 | 'unicorn/no-array-reduce': 'off', 35 | 'unicorn/no-nested-ternary': 'off', 36 | 'unicorn/no-useless-undefined': 'off', // We use a lot of `return undefined` to satisfy the `consistent-return` rule. 37 | 'unicorn/prevent-abbreviations': 'off', 38 | 39 | // typescript-eslint rules: 40 | '@typescript-eslint/no-unnecessary-condition': 'off', // Dozens of places where the rule's `meta` property needs optional chaining get flagged by this. 41 | '@typescript-eslint/no-unused-vars': 'off', 42 | '@typescript-eslint/prefer-readonly': 'error', 43 | '@typescript-eslint/require-array-sort-compare': 'error', 44 | 45 | // Optional eslint rules: 46 | 'array-callback-return': 'error', 47 | 'block-scoped-var': 'error', 48 | complexity: 'error', 49 | 'consistent-return': 'error', 50 | curly: 'error', 51 | 'default-case': 'error', 52 | eqeqeq: 'error', 53 | 'func-style': ['error', 'declaration'], 54 | 'new-parens': 'error', 55 | 'no-async-promise-executor': 'error', 56 | 'no-eval': 'error', 57 | 'no-extend-native': 'error', 58 | 'no-extra-bind': 'error', 59 | 'no-implicit-coercion': 'error', 60 | 'no-implied-eval': 'error', 61 | 'no-lone-blocks': 'error', 62 | 'no-multiple-empty-lines': 'error', 63 | 'no-new-func': 'error', 64 | 'no-new-wrappers': 'error', 65 | 'no-octal-escape': 'error', 66 | 'no-param-reassign': ['error', { props: true }], 67 | 'no-return-assign': 'error', 68 | 'no-return-await': 'error', 69 | 'no-self-compare': 'error', 70 | 'no-sequences': 'error', 71 | 'no-shadow-restricted-names': 'error', 72 | 'no-template-curly-in-string': 'error', 73 | 'no-throw-literal': 'error', 74 | 'no-unused-expressions': [ 75 | 'error', 76 | { allowShortCircuit: true, allowTernary: true }, 77 | ], 78 | 'no-use-before-define': ['error', 'nofunc'], 79 | 'no-useless-call': 'error', 80 | 'no-useless-catch': 'error', 81 | 'no-useless-computed-key': 'error', 82 | 'no-useless-concat': 'error', 83 | 'no-useless-constructor': 'error', 84 | 'no-useless-escape': 'error', 85 | 'no-useless-rename': 'error', 86 | 'no-useless-return': 'error', 87 | 'no-var': 'error', 88 | 'no-void': 'error', 89 | 'no-with': 'error', 90 | 'object-shorthand': 'error', 91 | 'prefer-const': 'error', 92 | 'prefer-numeric-literals': 'error', 93 | 'prefer-promise-reject-errors': 'error', 94 | 'prefer-rest-params': 'error', 95 | 'prefer-spread': 'error', 96 | 'prefer-template': 'error', 97 | quotes: [ 98 | 'error', 99 | 'single', // Must match quote style enforced by prettier. 100 | // Disallow unnecessary template literals. 101 | { avoidEscape: true, allowTemplateLiterals: false }, 102 | ], 103 | radix: 'error', 104 | 'require-atomic-updates': 'error', 105 | 'require-await': 'error', 106 | 'require-unicode-regexp': 'error', 107 | 'spaced-comment': ['error', 'always', { markers: ['*', '!'] }], 108 | 'sort-vars': 'error', 109 | yoda: 'error', 110 | 111 | // import rules: 112 | 'import/default': 'off', 113 | 'import/export': 'error', 114 | 'import/extensions': ['error', 'always'], 115 | 'import/first': 'error', 116 | 'import/named': 'error', 117 | 'import/namespace': 'off', 118 | 'import/newline-after-import': 'error', 119 | 'import/no-absolute-path': 'error', 120 | 'import/no-cycle': 'off', 121 | 'import/no-deprecated': 'off', 122 | 'import/no-duplicates': 'error', 123 | 'import/no-dynamic-require': 'error', 124 | 'import/no-mutable-exports': 'error', 125 | 'import/no-named-as-default': 'off', 126 | 'import/no-named-as-default-member': 'off', 127 | 'import/no-named-default': 'error', 128 | 'import/no-self-import': 'error', 129 | 'import/no-unassigned-import': 'error', 130 | 'import/no-unresolved': 'off', 131 | 'import/no-unused-modules': 'error', 132 | 'import/no-useless-path-segments': 'error', 133 | 'import/no-webpack-loader-syntax': 'error', 134 | }, 135 | 136 | // typescript-eslint parser options: 137 | languageOptions: { 138 | parserOptions: { 139 | projectService: true, 140 | tsconfigRootDir: import.meta.dirname, // eslint-disable-line n/no-unsupported-features/node-builtins 141 | }, 142 | }, 143 | 144 | linterOptions: { 145 | reportUnusedDisableDirectives: 'error', 146 | }, 147 | }, 148 | 149 | // Disabling type-checking for JS files: 150 | { 151 | files: ['**/*.js', '**/*.cjs'], 152 | extends: [tseslint.configs.disableTypeChecked], 153 | }, 154 | 155 | // Ignore files: 156 | { 157 | ignores: [ 158 | // compiled output 159 | 'dist/**', 160 | 161 | // test 162 | 'coverage/**', 163 | 'test/fixtures/**', 164 | ], 165 | }, 166 | ); 167 | -------------------------------------------------------------------------------- /lib/package-json.ts: -------------------------------------------------------------------------------- 1 | import { 2 | join, 3 | resolve, 4 | basename, 5 | dirname, 6 | isAbsolute, 7 | extname, 8 | } from 'node:path'; 9 | import { existsSync } from 'node:fs'; 10 | import { importAbs } from './import.js'; 11 | import { createRequire } from 'node:module'; 12 | import type { Plugin } from './types.js'; 13 | import type { PackageJson } from 'type-fest'; 14 | import { readdir, readFile } from 'node:fs/promises'; 15 | 16 | const require = createRequire(import.meta.url); 17 | 18 | export function getPluginRoot(path: string) { 19 | return isAbsolute(path) ? path : join(process.cwd(), path); 20 | } 21 | 22 | async function loadPackageJson(path: string): Promise { 23 | const pluginRoot = getPluginRoot(path); 24 | const pluginPackageJsonPath = join(pluginRoot, 'package.json'); 25 | if (!existsSync(pluginPackageJsonPath)) { 26 | throw new Error('Could not find package.json of ESLint plugin.'); 27 | } 28 | const pluginPackageJson = JSON.parse( 29 | await readFile(join(pluginRoot, 'package.json'), 'utf8'), 30 | ) as PackageJson; 31 | 32 | return pluginPackageJson; 33 | } 34 | 35 | export async function loadPlugin(path: string): Promise { 36 | const pluginRoot = getPluginRoot(path); 37 | try { 38 | /** 39 | * Try require first which should work for CJS plugins. 40 | * From Node 22 requiring on ESM module returns the module object 41 | * @see https://github.com/bmish/eslint-doc-generator/issues/615 42 | */ 43 | type cjsOrEsmPlugin = 44 | | Plugin 45 | | { 46 | __esModule: boolean; 47 | default: Plugin; 48 | /* some plugins might have additional exports besides `default` */ 49 | [key: string]: unknown; 50 | }; 51 | // eslint-disable-next-line import/no-dynamic-require 52 | const _plugin = require(pluginRoot) as cjsOrEsmPlugin; 53 | 54 | /* istanbul ignore next */ 55 | if ( 56 | '__esModule' in _plugin && 57 | _plugin.__esModule && 58 | // Ensure that we return only the default key when only a default export is present 59 | // @see https://github.com/bmish/eslint-doc-generator/issues/656#issuecomment-2726745618 60 | Object.keys(_plugin).length === 2 && 61 | ['__esModule', 'default'].every((it) => Boolean(_plugin[it])) 62 | ) { 63 | return _plugin.default; 64 | } 65 | return _plugin as Plugin; 66 | } catch (error) { 67 | // Otherwise, for ESM plugins, we'll have to try to resolve the exact plugin entry point and import it. 68 | const pluginPackageJson = await loadPackageJson(path); 69 | let pluginEntryPoint; 70 | const exports = pluginPackageJson.exports; 71 | if (typeof exports === 'string') { 72 | pluginEntryPoint = exports; 73 | } else if ( 74 | typeof exports === 'object' && 75 | exports !== null && 76 | !Array.isArray(exports) 77 | ) { 78 | // Check various properties on the `exports` object. 79 | // https://nodejs.org/api/packages.html#conditional-exports 80 | const propertiesToCheck: readonly (keyof PackageJson.ExportConditions)[] = 81 | ['.', 'node', 'import', 'require', 'default']; 82 | for (const prop of propertiesToCheck) { 83 | const value = exports[prop]; 84 | if (typeof value === 'string') { 85 | pluginEntryPoint = value; 86 | break; 87 | } 88 | } 89 | } 90 | 91 | if (pluginPackageJson.type === 'module' && !exports) { 92 | pluginEntryPoint = pluginPackageJson.main; 93 | } 94 | 95 | // If the ESM export doesn't exist, fall back to throwing the CJS error 96 | // (if the ESM export does exist, we'll validate it next) 97 | if (!pluginEntryPoint) { 98 | throw error; 99 | } 100 | 101 | const pluginEntryPointAbs = join(pluginRoot, pluginEntryPoint); 102 | if (!existsSync(pluginEntryPointAbs)) { 103 | throw new Error( 104 | `ESLint plugin entry point does not exist. Tried: ${pluginEntryPoint}`, 105 | ); 106 | } 107 | 108 | if (extname(pluginEntryPointAbs) === '.json') { 109 | // For JSON files, have to require() instead of import(..., { assert: { type: 'json' } }) because of this error: 110 | // Dynamic imports only support a second argument when the '--module' option is set to 'esnext', 'node16', or 'nodenext'. ts(1324) 111 | // TODO: Switch to import() when we drop support for Node 14. https://github.com/bmish/eslint-doc-generator/issues/585 112 | return require(pluginEntryPointAbs) as Plugin; // eslint-disable-line import/no-dynamic-require 113 | } 114 | 115 | const { default: plugin } = (await importAbs(pluginEntryPointAbs)) as { 116 | default: Plugin; 117 | }; 118 | return plugin; 119 | } 120 | } 121 | 122 | /** 123 | * Get the plugin name by reading the `name` field in the package.json file. 124 | */ 125 | export async function getPluginName(path: string): Promise { 126 | const pluginPackageJson = await loadPackageJson(path); 127 | if (!pluginPackageJson.name) { 128 | throw new Error( 129 | "Could not find `name` field in ESLint plugin's package.json.", 130 | ); 131 | } 132 | return pluginPackageJson.name; 133 | } 134 | 135 | /** 136 | * Resolve the path to a file but with the exact filename-casing present on disk. 137 | */ 138 | export async function getPathWithExactFileNameCasing(path: string) { 139 | const dir = dirname(path); 140 | const fileNameToSearch = basename(path); 141 | const filenames = await readdir(dir, { withFileTypes: true }); 142 | for (const dirent of filenames) { 143 | if ( 144 | dirent.isFile() && 145 | dirent.name.toLowerCase() === fileNameToSearch.toLowerCase() 146 | ) { 147 | return resolve(dir, dirent.name); 148 | } 149 | } 150 | return undefined; 151 | } 152 | 153 | export async function getCurrentPackageVersion(): Promise { 154 | // When running as compiled code, use path relative to compiled version of this file in the dist folder. 155 | // When running as TypeScript (in a test), use path relative to this file. 156 | const pathToPackageJson = import.meta.url.endsWith('.ts') 157 | ? '../package.json' 158 | : /* istanbul ignore next -- can't test the compiled version in test */ 159 | '../../package.json'; 160 | const packageJson = JSON.parse( 161 | await readFile(new URL(pathToPackageJson, import.meta.url), 'utf8'), 162 | ) as PackageJson; 163 | if (!packageJson.version) { 164 | throw new Error('Could not find package.json `version`.'); 165 | } 166 | return packageJson.version; 167 | } 168 | -------------------------------------------------------------------------------- /test/lib/__snapshots__/cli-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cli all CLI options and all config files options merges correctly, with CLI options taking precedence 1`] = ` 4 | [ 5 | ".", 6 | { 7 | "check": true, 8 | "configEmoji": [ 9 | [ 10 | "recommended-from-config-file", 11 | "🚲", 12 | ], 13 | [ 14 | "recommended-from-cli", 15 | "🚲", 16 | ], 17 | ], 18 | "configFormat": "plugin-colon-prefix-name", 19 | "ignoreConfig": [ 20 | "ignoredConfigFromConfigFile1", 21 | "ignoredConfigFromConfigFile2", 22 | "ignoredConfigFromCli1", 23 | "ignoredConfigFromCli2", 24 | ], 25 | "ignoreDeprecatedRules": true, 26 | "initRuleDocs": false, 27 | "pathRuleDoc": "www.example.com/rule-doc-from-cli", 28 | "pathRuleList": [ 29 | "www.example.com/rule-list-from-config-file", 30 | "www.example.com/rule-list-from-cli", 31 | ], 32 | "ruleDocNotices": [ 33 | "fixable", 34 | "type", 35 | ], 36 | "ruleDocSectionExclude": [ 37 | "excludedSectionFromConfigFile1", 38 | "excludedSectionFromConfigFile2", 39 | "excludedSectionFromCli1", 40 | "excludedSectionFromCli2", 41 | ], 42 | "ruleDocSectionInclude": [ 43 | "includedSectionFromConfigFile1", 44 | "includedSectionFromConfigFile2", 45 | "includedSectionFromCli1", 46 | "includedSectionFromCli2", 47 | ], 48 | "ruleDocSectionOptions": true, 49 | "ruleDocTitleFormat": "name", 50 | "ruleListColumns": [ 51 | "fixable", 52 | "hasSuggestions", 53 | "type", 54 | ], 55 | "ruleListSplit": [ 56 | "meta.docs.foo-from-config-file", 57 | "meta.docs.foo-from-cli", 58 | ], 59 | "urlConfigs": "https://example.com/configs-url-from-cli", 60 | "urlRuleDoc": "https://example.com/rule-doc-url-from-cli", 61 | }, 62 | ] 63 | `; 64 | 65 | exports[`cli all CLI options, no config file options is called correctly 1`] = ` 66 | [ 67 | ".", 68 | { 69 | "check": true, 70 | "configEmoji": [ 71 | [ 72 | "recommended-from-cli", 73 | "🚲", 74 | ], 75 | ], 76 | "configFormat": "plugin-colon-prefix-name", 77 | "ignoreConfig": [ 78 | "ignoredConfigFromCli1", 79 | "ignoredConfigFromCli2", 80 | ], 81 | "ignoreDeprecatedRules": true, 82 | "initRuleDocs": false, 83 | "pathRuleDoc": "www.example.com/rule-doc-from-cli", 84 | "pathRuleList": [ 85 | "www.example.com/rule-list-from-cli", 86 | ], 87 | "ruleDocNotices": [ 88 | "type", 89 | ], 90 | "ruleDocSectionExclude": [ 91 | "excludedSectionFromCli1", 92 | "excludedSectionFromCli2", 93 | ], 94 | "ruleDocSectionInclude": [ 95 | "includedSectionFromCli1", 96 | "includedSectionFromCli2", 97 | ], 98 | "ruleDocSectionOptions": true, 99 | "ruleDocTitleFormat": "name", 100 | "ruleListColumns": [ 101 | "type", 102 | ], 103 | "ruleListSplit": [ 104 | "meta.docs.foo-from-cli", 105 | ], 106 | "urlConfigs": "https://example.com/configs-url-from-cli", 107 | "urlRuleDoc": "https://example.com/rule-doc-url-from-cli", 108 | }, 109 | ] 110 | `; 111 | 112 | exports[`cli all config files options, no CLI options is called correctly 1`] = ` 113 | [ 114 | ".", 115 | { 116 | "check": true, 117 | "configEmoji": [ 118 | [ 119 | "recommended-from-config-file", 120 | "🚲", 121 | ], 122 | ], 123 | "configFormat": "name", 124 | "ignoreConfig": [ 125 | "ignoredConfigFromConfigFile1", 126 | "ignoredConfigFromConfigFile2", 127 | ], 128 | "ignoreDeprecatedRules": true, 129 | "initRuleDocs": true, 130 | "pathRuleDoc": "www.example.com/rule-doc-from-config-file", 131 | "pathRuleList": [ 132 | "www.example.com/rule-list-from-config-file", 133 | ], 134 | "ruleDocNotices": [ 135 | "fixable", 136 | ], 137 | "ruleDocSectionExclude": [ 138 | "excludedSectionFromConfigFile1", 139 | "excludedSectionFromConfigFile2", 140 | ], 141 | "ruleDocSectionInclude": [ 142 | "includedSectionFromConfigFile1", 143 | "includedSectionFromConfigFile2", 144 | ], 145 | "ruleDocSectionOptions": false, 146 | "ruleDocTitleFormat": "desc", 147 | "ruleListColumns": [ 148 | "fixable", 149 | "hasSuggestions", 150 | ], 151 | "ruleListSplit": [ 152 | "meta.docs.foo-from-config-file", 153 | ], 154 | "urlConfigs": "https://example.com/configs-url-from-config-file", 155 | "urlRuleDoc": "https://example.com/rule-doc-url-from-config-file", 156 | }, 157 | ] 158 | `; 159 | 160 | exports[`cli boolean option - false (explicit) is called correctly 1`] = ` 161 | [ 162 | ".", 163 | { 164 | "configEmoji": [], 165 | "ignoreConfig": [], 166 | "ignoreDeprecatedRules": false, 167 | "pathRuleList": [], 168 | "ruleDocNotices": [], 169 | "ruleDocSectionExclude": [], 170 | "ruleDocSectionInclude": [], 171 | "ruleListColumns": [], 172 | "ruleListSplit": [], 173 | }, 174 | ] 175 | `; 176 | 177 | exports[`cli boolean option - true (explicit) is called correctly 1`] = ` 178 | [ 179 | ".", 180 | { 181 | "configEmoji": [], 182 | "ignoreConfig": [], 183 | "ignoreDeprecatedRules": true, 184 | "pathRuleList": [], 185 | "ruleDocNotices": [], 186 | "ruleDocSectionExclude": [], 187 | "ruleDocSectionInclude": [], 188 | "ruleListColumns": [], 189 | "ruleListSplit": [], 190 | }, 191 | ] 192 | `; 193 | 194 | exports[`cli boolean option - true (implicit) is called correctly 1`] = ` 195 | [ 196 | ".", 197 | { 198 | "configEmoji": [], 199 | "ignoreConfig": [], 200 | "ignoreDeprecatedRules": true, 201 | "pathRuleList": [], 202 | "ruleDocNotices": [], 203 | "ruleDocSectionExclude": [], 204 | "ruleDocSectionInclude": [], 205 | "ruleListColumns": [], 206 | "ruleListSplit": [], 207 | }, 208 | ] 209 | `; 210 | 211 | exports[`cli no options is called correctly 1`] = ` 212 | [ 213 | ".", 214 | { 215 | "configEmoji": [], 216 | "ignoreConfig": [], 217 | "pathRuleList": [], 218 | "ruleDocNotices": [], 219 | "ruleDocSectionExclude": [], 220 | "ruleDocSectionInclude": [], 221 | "ruleListColumns": [], 222 | "ruleListSplit": [], 223 | }, 224 | ] 225 | `; 226 | 227 | exports[`cli pathRuleList as array in config file and CLI merges correctly 1`] = ` 228 | [ 229 | ".", 230 | { 231 | "configEmoji": [], 232 | "ignoreConfig": [], 233 | "pathRuleList": [ 234 | "listFromConfigFile1.md", 235 | "listFromConfigFile2.md", 236 | "listFromCli1.md", 237 | "listFromCli2.md", 238 | ], 239 | "ruleDocNotices": [], 240 | "ruleDocSectionExclude": [], 241 | "ruleDocSectionInclude": [], 242 | "ruleListColumns": [], 243 | "ruleListSplit": [], 244 | }, 245 | ] 246 | `; 247 | -------------------------------------------------------------------------------- /test/lib/generate/__snapshots__/configs-list-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`generate (configs list) basic generates the documentation 1`] = ` 4 | "## Rules 5 | 6 | 7 | | Name | Description | 8 | | :----------------------------- | :--------------------- | 9 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 10 | 11 | 12 | ## Configs 13 | 14 | 15 | | | Name | 16 | | :- | :------------ | 17 | | ✅ | \`recommended\` | 18 | 19 | " 20 | `; 21 | 22 | exports[`generate (configs list) when a config description needs to be escaped in table generates the documentation 1`] = ` 23 | "## Rules 24 | 25 | 26 | | Name | Description | 27 | | :----------------------------- | :------------------ | 28 | | [no-foo](docs/rules/no-foo.md) | Description no-foo. | 29 | 30 | 31 | ## Configs 32 | 33 | 34 | | | Name | Description | 35 | | :- | :------------ | :---------- | 36 | | ✅ | \`recommended\` | Foo\\|Bar | 37 | 38 | " 39 | `; 40 | 41 | exports[`generate (configs list) when a config exports a description property=description generates the documentation 1`] = ` 42 | "## Rules 43 | 44 | 45 | | Name | Description | 46 | | :----------------------------- | :--------------------- | 47 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 48 | 49 | 50 | ## Configs 51 | 52 | 53 | | | Name | Description | 54 | | :- | :------------ | :--------------------------------------- | 55 | | | \`foo\` | | 56 | | ✅ | \`recommended\` | This config has the recommended rules... | 57 | 58 | " 59 | `; 60 | 61 | exports[`generate (configs list) when a config exports a description property=meta.description generates the documentation 1`] = ` 62 | "## Rules 63 | 64 | 65 | | Name | Description | 66 | | :----------------------------- | :--------------------- | 67 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 68 | 69 | 70 | ## Configs 71 | 72 | 73 | | | Name | Description | 74 | | :- | :------------ | :--------------------------------------- | 75 | | | \`foo\` | | 76 | | ✅ | \`recommended\` | This config has the recommended rules... | 77 | 78 | " 79 | `; 80 | 81 | exports[`generate (configs list) when a config exports a description property=meta.docs.description generates the documentation 1`] = ` 82 | "## Rules 83 | 84 | 85 | | Name | Description | 86 | | :----------------------------- | :--------------------- | 87 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 88 | 89 | 90 | ## Configs 91 | 92 | 93 | | | Name | Description | 94 | | :- | :------------ | :--------------------------------------- | 95 | | | \`foo\` | | 96 | | ✅ | \`recommended\` | This config has the recommended rules... | 97 | 98 | " 99 | `; 100 | 101 | exports[`generate (configs list) when all configs are ignored generates the documentation 1`] = ` 102 | "## Rules 103 | 104 | 105 | | Name | Description | 106 | | :----------------------------- | :--------------------- | 107 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 108 | 109 | 110 | ## Configs 111 | 112 | " 113 | `; 114 | 115 | exports[`generate (configs list) when there are no configs generates the documentation 1`] = ` 116 | "## Rules 117 | 118 | 119 | | Name | Description | 120 | | :----------------------------- | :--------------------- | 121 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 122 | 123 | 124 | ## Configs 125 | 126 | " 127 | `; 128 | 129 | exports[`generate (configs list) with --config-format generates the documentation 1`] = ` 130 | "## Rules 131 | 132 | 133 | | Name | Description | 134 | | :----------------------------- | :--------------------- | 135 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 136 | 137 | 138 | ## Configs 139 | 140 | 141 | | | Name | 142 | | :- | :----------------- | 143 | | ✅ | \`test/recommended\` | 144 | 145 | " 146 | `; 147 | 148 | exports[`generate (configs list) with --ignore-config generates the documentation 1`] = ` 149 | "## Rules 150 | 151 | 152 | | Name | Description | 153 | | :----------------------------- | :--------------------- | 154 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 155 | 156 | 157 | ## Configs 158 | 159 | 160 | | | Name | 161 | | :- | :------------ | 162 | | ✅ | \`recommended\` | 163 | 164 | " 165 | `; 166 | 167 | exports[`generate (configs list) with configs not defined in alphabetical order generates the documentation 1`] = ` 168 | "## Rules 169 | 170 | 171 | | Name | Description | 172 | | :----------------------------- | :--------------------- | 173 | | [no-foo](docs/rules/no-foo.md) | Description of no-foo. | 174 | 175 | 176 | ## Configs 177 | 178 | 179 | | | Name | 180 | | :- | :------------ | 181 | | | \`foo\` | 182 | | ✅ | \`recommended\` | 183 | 184 | " 185 | `; 186 | -------------------------------------------------------------------------------- /test/lib/generate/option-rule-doc-title-format-test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../../lib/generator.js'; 2 | import mockFs from 'mock-fs'; 3 | import { dirname, resolve } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { jest } from '@jest/globals'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); 11 | 12 | describe('generate (--rule-doc-title-format)', function () { 13 | describe('desc-parens-prefix-name', function () { 14 | beforeEach(function () { 15 | mockFs({ 16 | 'package.json': JSON.stringify({ 17 | name: 'eslint-plugin-test', 18 | exports: 'index.js', 19 | type: 'module', 20 | }), 21 | 22 | 'index.js': ` 23 | export default { 24 | rules: { 25 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 26 | 'no-bar': { meta: { docs: {} }, create(context) {} }, // No description. 27 | }, 28 | };`, 29 | 30 | 'README.md': '## Rules\n', 31 | 32 | 'docs/rules/no-foo.md': '', 33 | 'docs/rules/no-bar.md': '', 34 | 35 | // Needed for some of the test infrastructure to work. 36 | node_modules: mockFs.load(PATH_NODE_MODULES), 37 | }); 38 | }); 39 | 40 | afterEach(function () { 41 | mockFs.restore(); 42 | jest.resetModules(); 43 | }); 44 | 45 | it('uses the right rule doc title format, with fallback when missing description', async function () { 46 | await generate('.', { 47 | ruleDocTitleFormat: 'desc-parens-prefix-name', 48 | }); 49 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 50 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 51 | }); 52 | }); 53 | 54 | describe('desc-parens-name', function () { 55 | beforeEach(function () { 56 | mockFs({ 57 | 'package.json': JSON.stringify({ 58 | name: 'eslint-plugin-test', 59 | exports: 'index.js', 60 | type: 'module', 61 | }), 62 | 63 | 'index.js': ` 64 | export default { 65 | rules: { 66 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 67 | 'no-bar': { meta: { docs: { /* one rule without description */ } }, create(context) {} }, 68 | }, 69 | };`, 70 | 71 | 'README.md': '## Rules\n', 72 | 73 | 'docs/rules/no-foo.md': '', 74 | 'docs/rules/no-bar.md': '', 75 | 76 | // Needed for some of the test infrastructure to work. 77 | node_modules: mockFs.load(PATH_NODE_MODULES), 78 | }); 79 | }); 80 | 81 | afterEach(function () { 82 | mockFs.restore(); 83 | jest.resetModules(); 84 | }); 85 | 86 | it('uses the right rule doc title format', async function () { 87 | await generate('.', { 88 | ruleDocTitleFormat: 'desc-parens-name', 89 | }); 90 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 91 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 92 | }); 93 | }); 94 | 95 | describe('desc', function () { 96 | beforeEach(function () { 97 | mockFs({ 98 | 'package.json': JSON.stringify({ 99 | name: 'eslint-plugin-test', 100 | exports: 'index.js', 101 | type: 'module', 102 | }), 103 | 104 | 'index.js': ` 105 | export default { 106 | rules: { 107 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 108 | 'no-bar': { meta: { docs: { /* one rule without description */ } }, create(context) {} }, 109 | }, 110 | };`, 111 | 112 | 'README.md': '## Rules\n', 113 | 114 | 'docs/rules/no-foo.md': '', 115 | 'docs/rules/no-bar.md': '', 116 | 117 | // Needed for some of the test infrastructure to work. 118 | node_modules: mockFs.load(PATH_NODE_MODULES), 119 | }); 120 | }); 121 | 122 | afterEach(function () { 123 | mockFs.restore(); 124 | jest.resetModules(); 125 | }); 126 | 127 | it('uses the right rule doc title format', async function () { 128 | await generate('.', { 129 | ruleDocTitleFormat: 'desc', 130 | }); 131 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 132 | expect(readFileSync('docs/rules/no-bar.md', 'utf8')).toMatchSnapshot(); 133 | }); 134 | }); 135 | 136 | describe('prefix-name', function () { 137 | beforeEach(function () { 138 | mockFs({ 139 | 'package.json': JSON.stringify({ 140 | name: 'eslint-plugin-test', 141 | exports: 'index.js', 142 | type: 'module', 143 | }), 144 | 145 | 'index.js': ` 146 | export default { 147 | rules: { 148 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 149 | }, 150 | };`, 151 | 152 | 'README.md': '## Rules\n', 153 | 154 | 'docs/rules/no-foo.md': '', 155 | 156 | // Needed for some of the test infrastructure to work. 157 | node_modules: mockFs.load(PATH_NODE_MODULES), 158 | }); 159 | }); 160 | 161 | afterEach(function () { 162 | mockFs.restore(); 163 | jest.resetModules(); 164 | }); 165 | 166 | it('uses the right rule doc title format', async function () { 167 | await generate('.', { 168 | ruleDocTitleFormat: 'prefix-name', 169 | }); 170 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 171 | }); 172 | }); 173 | 174 | describe('name', function () { 175 | beforeEach(function () { 176 | mockFs({ 177 | 'package.json': JSON.stringify({ 178 | name: 'eslint-plugin-test', 179 | exports: 'index.js', 180 | type: 'module', 181 | }), 182 | 183 | 'index.js': ` 184 | export default { 185 | rules: { 186 | 'no-foo': { meta: { docs: { description: 'Description for no-foo.'} }, create(context) {} }, 187 | }, 188 | };`, 189 | 190 | 'README.md': '## Rules\n', 191 | 192 | 'docs/rules/no-foo.md': '', 193 | 194 | // Needed for some of the test infrastructure to work. 195 | node_modules: mockFs.load(PATH_NODE_MODULES), 196 | }); 197 | }); 198 | 199 | afterEach(function () { 200 | mockFs.restore(); 201 | jest.resetModules(); 202 | }); 203 | 204 | it('uses the right rule doc title format', async function () { 205 | await generate('.', { 206 | ruleDocTitleFormat: 'name', 207 | }); 208 | expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot(); 209 | }); 210 | }); 211 | }); 212 | --------------------------------------------------------------------------------