diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 0db35d2..ca2b6f0 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -15,6 +15,8 @@ body: required: false - label: "`@eslint/config-array`" required: false + - label: "`@eslint/config-helpers`" + required: false - label: "`@eslint/core`" required: false - label: "`@eslint/migrate-config`" diff --git a/.github/ISSUE_TEMPLATE/change.yml b/.github/ISSUE_TEMPLATE/change.yml index 551c780..b0ae51d 100644 --- a/.github/ISSUE_TEMPLATE/change.yml +++ b/.github/ISSUE_TEMPLATE/change.yml @@ -14,6 +14,8 @@ body: required: false - label: "`@eslint/config-array`" required: false + - label: "`@eslint/config-helpers`" + required: false - label: "`@eslint/core`" required: false - label: "`@eslint/migrate-config`" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e9fe2f..a52a437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,18 +65,24 @@ jobs: with: node-version: "lts/*" - - name: npm install and test types (core) - working-directory: packages/core + - name: Install Packages run: | npm install npm run build + + - name: Test types (core) + working-directory: packages/core + run: | npm run test:types - - name: npm install and test types (plugin-kit) + - name: Test types (config-helpers) + working-directory: packages/config-helpers + run: | + npm run test:types + + - name: Test types (plugin-kit) working-directory: packages/plugin-kit run: | - npm install - npm run build npm run test:types jsr_test: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 00ce010..e355c88 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -226,3 +226,32 @@ jobs: BLUESKY_IDENTIFIER: ${{ vars.BLUESKY_IDENTIFIER }} BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} BLUESKY_HOST: ${{ vars.BLUESKY_HOST }} + + #----------------------------------------------------------------------------- + # @eslint/config-helpers + #----------------------------------------------------------------------------- + + - name: Publish @eslint/config-helpers package to npm + run: npm publish -w packages/config-helpers --provenance + if: ${{ steps.release.outputs['packages/config-helpers--release_created'] }} + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + + - name: Publish @eslint/config-helpers package to JSR + run: npx jsr publish + working-directory: packages/config-helpers + if: ${{ steps.release.outputs['packages/config-helpers--release_created'] }} + + - name: Post Release Announcement + run: npx @humanwhocodes/crosspost -t -b -m "eslint/config-helpers v${{ steps.release.outputs['packages/config-helpers--major'] }}.${{ steps.release.outputs['packages/config-helpers--minor'] }}.${{ steps.release.outputs['packages/config-helpers--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/config-helpers--tag_name'] }}" + if: ${{ steps.release.outputs['packages/config-helpers--release_created'] }} + env: + TWITTER_API_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} + TWITTER_API_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} + TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} + MASTODON_HOST: ${{ secrets.MASTODON_HOST }} + BLUESKY_IDENTIFIER: ${{ vars.BLUESKY_IDENTIFIER }} + BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }} + BLUESKY_HOST: ${{ vars.BLUESKY_HOST }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a4eafcd..50515dc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,6 +3,7 @@ "packages/config-array": "0.19.2", "packages/core": "0.12.0", "packages/migrate-config": "1.3.8", + "packages/config-helpers": "0.0.0", "packages/object-schema": "2.1.6", "packages/plugin-kit": "0.2.7" } diff --git a/README.md b/README.md index 6fb719c..f35be1f 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ This repository is the home of the following packages: -- [`@eslint/core`](packages/core) -- [`@eslint/compat`](packages/compat) -- [`@eslint/config-array`](packages/config-array) -- [`@eslint/object-schema`](packages/object-schema) -- [`@eslint/migrate-config`](packages/migrate-config) +- [`@eslint/compat`](./packages/compat) +- [`@eslint/config-array`](./packages/config-array) +- [`@eslint/config-helpers`](./packages/config-helpers) +- [`@eslint/core`](./packages/core) +- [`@eslint/migrate-config`](./packages/migrate-config) +- [`@eslint/object-schema`](./packages/object-schema) +- [`@eslint/plugin-kit`](./packages/plugin-kit) diff --git a/eslint.config.js b/eslint.config.js index cbb3778..7e0c971 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,18 +1,18 @@ import eslintConfigESLint from "eslint-config-eslint"; +import { defineConfig } from "@eslint/config-helpers"; import tseslint from "typescript-eslint"; const eslintPluginJSDoc = eslintConfigESLint.find( config => config.plugins?.jsdoc, ).plugins.jsdoc; -export default [ +export default defineConfig([ { ignores: ["**/tests/fixtures/", "**/dist/", "**/coverage/"], }, - ...eslintConfigESLint, - { + extends: [eslintConfigESLint], rules: { // disable rules we don't want to use from eslint-config-eslint "no-undefined": "off", @@ -64,4 +64,4 @@ export default [ "no-use-before-define": "off", }, }), -]; +]); diff --git a/package.json b/package.json index 1914f7d..5af0a0c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "node scripts/build.js", "build:readme": "node tools/update-readme.js", "build:new-pkg": "node tools/new-pkg.js", + "prepare": "npm run build", "lint": "eslint .", "lint:fix": "eslint --fix .", "fmt": "prettier --write .", @@ -32,6 +33,7 @@ "node": ">= 22.3.0" }, "devDependencies": { + "@eslint/config-helpers": "file:packages/config-helpers", "@types/mocha": "^10.0.7", "eslint": "^9.11.1", "eslint-config-eslint": "^11.0.0", diff --git a/packages/config-helpers/CHANGELOG.md b/packages/config-helpers/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/packages/config-helpers/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/packages/config-helpers/LICENSE b/packages/config-helpers/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packages/config-helpers/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/config-helpers/README.md b/packages/config-helpers/README.md new file mode 100644 index 0000000..7b1cae2 --- /dev/null +++ b/packages/config-helpers/README.md @@ -0,0 +1,63 @@ +# @eslint/config-helpers + +## Description + +Helper utilities for creating ESLint configuration. + +## Installation + +For Node.js and compatible runtimes: + +```shell +npm install @eslint/config-helpers +# or +yarn add @eslint/config-helpers +# or +pnpm install @eslint/config-helpers +# or +bun install @eslint/config-helpers +``` + +For Deno: + +```shell +deno add @eslint/config-helpers +``` + +## Usage + +### `defineConfig()` + +The `defineConfig()` function allows you to specify an ESLint configuration with full type checking and additional capabilities, such as `extends`. Here's an example: + +```js +// eslint.config.js +import { defineConfig } from "@eslint/config-helpers"; +import js from "@eslint/js"; + +export default defineConfig([ + { + files: ["src/**/*.js"], + plugins: { js }, + extends: ["js/recommended"], + rules: { + semi: "error", + "prefer-const": "error", + }, + }, + { + files: ["test/**/*.js"], + rules: { + "no-console": "off", + }, + }, +]); +``` + +## License + +Apache 2.0 + + + + diff --git a/packages/config-helpers/jsr.json b/packages/config-helpers/jsr.json new file mode 100644 index 0000000..a0de7c2 --- /dev/null +++ b/packages/config-helpers/jsr.json @@ -0,0 +1,16 @@ +{ + "name": "@eslint/config-helpers", + "version": "0.0.0", + "exports": "./dist/esm/index.js", + "publish": { + "include": [ + "dist/esm/index.js", + "dist/esm/index.d.ts", + "dist/esm/types.ts", + "dist/esm/types.d.ts", + "README.md", + "jsr.json", + "LICENSE" + ] + } +} diff --git a/packages/config-helpers/package.json b/packages/config-helpers/package.json new file mode 100644 index 0000000..530dfa9 --- /dev/null +++ b/packages/config-helpers/package.json @@ -0,0 +1,59 @@ +{ + "name": "@eslint/config-helpers", + "version": "0.0.0", + "description": "Helper utilities for creating ESLint configuration", + "type": "module", + "main": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "directories": { + "test": "tests" + }, + "scripts": { + "build:cts": "node ../../tools/build-cts.js dist/esm/index.d.ts dist/cjs/index.d.cts", + "build": "rollup -c && tsc -p tsconfig.esm.json && npm run build:cts", + "test:jsr": "npx jsr@latest publish --dry-run", + "test": "mocha tests/*.js", + "test:coverage": "c8 npm test", + "test:types": "tsc -p tests/types/tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/eslint/rewrite.git" + }, + "keywords": [ + "eslint" + ], + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/eslint/rewrite/issues" + }, + "homepage": "https://github.com/eslint/rewrite/tree/main/packages/config-helpers#readme", + "devDependencies": { + "@eslint/core": "^0.11.0", + "c8": "^9.1.0", + "eslint": "^9.19.0", + "mocha": "^10.4.0", + "rollup": "^4.16.2", + "rollup-plugin-copy": "^3.5.0", + "typescript": "^5.4.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } +} diff --git a/packages/config-helpers/rollup.config.js b/packages/config-helpers/rollup.config.js new file mode 100644 index 0000000..5b813a8 --- /dev/null +++ b/packages/config-helpers/rollup.config.js @@ -0,0 +1,24 @@ +import copy from "rollup-plugin-copy"; + +export default { + input: "src/index.js", + output: [ + { + file: "dist/cjs/index.cjs", + format: "cjs", + }, + { + file: "dist/esm/index.js", + format: "esm", + banner: '// @ts-self-types="./index.d.ts"', + }, + ], + plugins: [ + copy({ + targets: [ + { src: "src/types.ts", dest: "dist/cjs", rename: "types.cts" }, + { src: "src/types.ts", dest: "dist/esm" }, + ], + }), + ], +}; diff --git a/packages/config-helpers/src/define-config.js b/packages/config-helpers/src/define-config.js new file mode 100644 index 0000000..d185e4a --- /dev/null +++ b/packages/config-helpers/src/define-config.js @@ -0,0 +1,498 @@ +/** + * @fileoverview defineConfig helper + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Linter.Config} Config */ +/** @typedef {import("eslint").Linter.LegacyConfig} LegacyConfig */ +/** @typedef {import("eslint").ESLint.Plugin} Plugin */ +/** @typedef {import("eslint").Linter.RuleEntry} RuleEntry */ +/** @typedef {import("./types.ts").ExtendsElement} ExtendsElement */ +/** @typedef {import("./types.ts").SimpleExtendsElement} SimpleExtendsElement */ +/** @typedef {import("./types.ts").ConfigWithExtends} ConfigWithExtends */ +/** @typedef {import("./types.ts").InfiniteArray} InfiniteConfigArray */ +/** @typedef {import("./types.ts").ConfigWithExtendsArray} ConfigWithExtendsArray */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const eslintrcKeys = [ + "env", + "extends", + "globals", + "ignorePatterns", + "noInlineConfig", + "overrides", + "parser", + "parserOptions", + "reportUnusedDisableDirectives", + "root", +]; + +const allowedGlobalIgnoreKeys = new Set(["ignores", "name"]); + +/** + * Gets the name of a config object. + * @param {Config} config The config object. + * @param {string} indexPath The index path of the config object. + * @return {string} The name of the config object. + */ +function getConfigName(config, indexPath) { + if (config.name) { + return config.name; + } + + return `UserConfig${indexPath}`; +} + +/** + * Gets the name of an extension. + * @param {SimpleExtendsElement} extension The extension. + * @param {string} indexPath The index of the extension. + * @return {string} The name of the extension. + */ +function getExtensionName(extension, indexPath) { + if (typeof extension === "string") { + return extension; + } + + if (extension.name) { + return extension.name; + } + + return `ExtendedConfig${indexPath}`; +} + +/** + * Determines if a config object is a legacy config. + * @param {Config|LegacyConfig} config The config object to check. + * @return {config is LegacyConfig} `true` if the config object is a legacy config. + */ +function isLegacyConfig(config) { + for (const key of eslintrcKeys) { + if (key in config) { + return true; + } + } + + return false; +} + +/** + * Determines if a config object is a global ignores config. + * @param {Config} config The config object to check. + * @return {boolean} `true` if the config object is a global ignores config. + */ +function isGlobalIgnores(config) { + return Object.keys(config).every(key => allowedGlobalIgnoreKeys.has(key)); +} + +/** + * Parses a plugin member ID (rule, processor, etc.) and returns + * the namespace and member name. + * @param {string} id The ID to parse. + * @returns {{namespace:string, name:string}} The namespace and member name. + */ +function getPluginMember(id) { + const firstSlashIndex = id.indexOf("/"); + + if (firstSlashIndex === -1) { + return { namespace: "", name: id }; + } + + let namespace = id.slice(0, firstSlashIndex); + + /* + * Special cases: + * 1. The namespace is `@`, that means it's referring to the + * core plugin so `@` is the full namespace. + * 2. The namespace starts with `@`, that means it's referring to + * an npm scoped package. That means the namespace is the scope + * and the package name (i.e., `@eslint/core`). + */ + if (namespace[0] === "@" && namespace !== "@") { + const secondSlashIndex = id.indexOf("/", firstSlashIndex + 1); + if (secondSlashIndex !== -1) { + namespace = id.slice(0, secondSlashIndex); + return { namespace, name: id.slice(secondSlashIndex + 1) }; + } + } + + const name = id.slice(firstSlashIndex + 1); + + return { namespace, name }; +} + +/** + * Normalizes the plugin config by replacing the namespace with the plugin namespace. + * @param {string} userNamespace The namespace of the plugin. + * @param {Plugin} plugin The plugin config object. + * @param {Config} config The config object to normalize. + * @return {Config} The normalized config object. + */ +function normalizePluginConfig(userNamespace, plugin, config) { + // @ts-ignore -- ESLint types aren't updated yet + const pluginNamespace = plugin.meta?.namespace; + + // don't do anything if the plugin doesn't have a namespace or rules + if ( + !pluginNamespace || + pluginNamespace === userNamespace || + (!config.rules && !config.processor && !config.language) + ) { + return config; + } + + const result = { ...config }; + + // update the rules + if (result.rules) { + const ruleIds = Object.keys(result.rules); + + /** @type {Record} */ + const newRules = {}; + + for (let i = 0; i < ruleIds.length; i++) { + const ruleId = ruleIds[i]; + const { namespace: ruleNamespace, name: ruleName } = + getPluginMember(ruleId); + + if (ruleNamespace === pluginNamespace) { + newRules[`${userNamespace}/${ruleName}`] = result.rules[ruleId]; + } else { + newRules[ruleId] = result.rules[ruleId]; + } + } + + result.rules = newRules; + } + + // update the processor + + if (typeof result.processor === "string") { + const { namespace: processorNamespace, name: processorName } = + getPluginMember(result.processor); + + if (processorNamespace) { + if (processorNamespace === pluginNamespace) { + result.processor = `${userNamespace}/${processorName}`; + } + } + } + + // update the language + if (typeof result.language === "string") { + const { namespace: languageNamespace, name: languageName } = + getPluginMember(result.language); + + if (languageNamespace === pluginNamespace) { + result.language = `${userNamespace}/${languageName}`; + } + } + + return result; +} + +/** + * Deeply normalizes a plugin config, traversing recursively into an arrays. + * @param {string} userPluginNamespace The namespace of the plugin. + * @param {Plugin} plugin The plugin object. + * @param {Config|LegacyConfig|(Config|LegacyConfig)[]} pluginConfig The plugin config to normalize. + * @param {string} pluginConfigName The name of the plugin config. + * @return {InfiniteConfigArray} The normalized plugin config. + */ +function deepNormalizePluginConfig( + userPluginNamespace, + plugin, + pluginConfig, + pluginConfigName, +) { + // if it's an array then it's definitely a new config + if (Array.isArray(pluginConfig)) { + return pluginConfig.map(pluginSubConfig => + deepNormalizePluginConfig( + userPluginNamespace, + plugin, + pluginSubConfig, + pluginConfigName, + ), + ); + } + + // if it's a legacy config, throw an error + if (isLegacyConfig(pluginConfig)) { + throw new TypeError( + `Plugin config "${pluginConfigName}" is an eslintrc config and cannot be used in this context.`, + ); + } + + return normalizePluginConfig(userPluginNamespace, plugin, pluginConfig); +} + +/** + * Finds a plugin config by name in the given config. + * @param {Config} config The config object. + * @param {string} pluginConfigName The name of the plugin config. + * @return {InfiniteConfigArray} The plugin config. + */ +function findPluginConfig(config, pluginConfigName) { + const { namespace: userPluginNamespace, name: configName } = + getPluginMember(pluginConfigName); + const plugin = config.plugins?.[userPluginNamespace]; + + if (!plugin) { + throw new TypeError(`Plugin "${userPluginNamespace}" not found.`); + } + + const pluginConfig = plugin.configs?.[configName]; + + if (!pluginConfig) { + throw new TypeError( + `Plugin config "${configName}" not found in plugin "${userPluginNamespace}".`, + ); + } + + return deepNormalizePluginConfig( + userPluginNamespace, + plugin, + pluginConfig, + pluginConfigName, + ); +} + +/** + * Flattens an array while keeping track of the index path. + * @param {any[]} configList The array to traverse. + * @param {string} indexPath The index path of the value in a multidimensional array. + * @return {IterableIterator<{indexPath:string, value:any}>} The flattened list of values. + */ +function* flatTraverse(configList, indexPath = "") { + for (let i = 0; i < configList.length; i++) { + const newIndexPath = indexPath ? `${indexPath}[${i}]` : `[${i}]`; + + // if it's an array then traverse it as well + if (Array.isArray(configList[i])) { + yield* flatTraverse(configList[i], newIndexPath); + continue; + } + + yield { indexPath: newIndexPath, value: configList[i] }; + } +} + +/** + * Extends a list of config files by creating every combination of base and extension files. + * @param {(string|string[])[]} [baseFiles] The base files. + * @param {(string|string[])[]} [extensionFiles] The extension files. + * @return {(string|string[])[]} The extended files. + */ +function extendConfigFiles(baseFiles = [], extensionFiles = []) { + if (!extensionFiles.length) { + return baseFiles.concat(); + } + + if (!baseFiles.length) { + return extensionFiles.concat(); + } + + /** @type {(string|string[])[]} */ + const result = []; + + for (const baseFile of baseFiles) { + for (const extensionFile of extensionFiles) { + /* + * Each entry can be a string or array of strings. The end result + * needs to be an array of strings, so we need to be sure to include + * all of the items when there's an array. + */ + + const entry = []; + + if (Array.isArray(baseFile)) { + entry.push(...baseFile); + } else { + entry.push(baseFile); + } + + if (Array.isArray(extensionFile)) { + entry.push(...extensionFile); + } else { + entry.push(extensionFile); + } + + result.push(entry); + } + } + + return result; +} + +/** + * Extends a config object with another config object. + * @param {Config} baseConfig The base config object. + * @param {string} baseConfigName The name of the base config object. + * @param {Config} extension The extension config object. + * @param {string} extensionName The index of the extension config object. + * @return {Config} The extended config object. + */ +function extendConfig(baseConfig, baseConfigName, extension, extensionName) { + const result = { ...extension }; + + // for global ignores there is no further work to be done, we just keep everything + if (!isGlobalIgnores(extension)) { + // for files we need to create every combination of base and extension files + if (baseConfig.files) { + result.files = extendConfigFiles(baseConfig.files, extension.files); + } + + // for ignores we just concatenation the extension ignores onto the base ignores + if (baseConfig.ignores) { + result.ignores = baseConfig.ignores.concat(extension.ignores ?? []); + } + } + + result.name = `${baseConfigName} > ${extensionName}`; + + return result; +} + +/** + * Processes a list of extends elements. + * @param {ConfigWithExtends} config The config object. + * @param {WeakMap} configNames The map of config objects to their names. + * @return {Config[]} The flattened list of config objects. + */ +function processExtends(config, configNames) { + if (!config.extends) { + return [config]; + } + + if (!Array.isArray(config.extends)) { + throw new TypeError("The `extends` property must be an array."); + } + + const { + /** @type {Config[]} */ + extends: extendsList, + + /** @type {Config} */ + ...configObject + } = config; + + const extensionNames = new WeakMap(); + + // replace strings with the actual configs + const objectExtends = extendsList.map(extendsElement => { + if (typeof extendsElement === "string") { + const pluginConfig = findPluginConfig(config, extendsElement); + + // assign names + if (Array.isArray(pluginConfig)) { + pluginConfig.forEach((pluginConfigElement, index) => { + extensionNames.set( + pluginConfigElement, + `${extendsElement}[${index}]`, + ); + }); + } else { + extensionNames.set(pluginConfig, extendsElement); + } + + return pluginConfig; + } + + return /** @type {Config} */ (extendsElement); + }); + + const result = []; + + for (const { indexPath, value: extendsElement } of flatTraverse( + objectExtends, + )) { + const extension = /** @type {Config} */ (extendsElement); + + if ("extends" in extension) { + throw new TypeError("Nested 'extends' is not allowed."); + } + + const baseConfigName = /** @type {string} */ (configNames.get(config)); + const extensionName = + extensionNames.get(extendsElement) ?? + getExtensionName(extendsElement, indexPath); + + result.push( + extendConfig( + configObject, + baseConfigName, + extension, + extensionName, + ), + ); + } + + /* + * If the base config object has only `ignores` and `extends`, then + * removing `extends` turns it into a global ignores, which is not what + * we want. So we need to check if the base config object is a global ignores + * and if so, we don't add it to the array. + * + * (The other option would be to add a `files` entry, but that would result + * in a config that didn't actually do anything because there are no + * other keys in the config.) + */ + if (!isGlobalIgnores(configObject)) { + result.push(configObject); + } + + return result.flat(); +} + +/** + * Processes a list of config objects and arrays. + * @param {ConfigWithExtends[]} configList The list of config objects and arrays. + * @param {WeakMap} configNames The map of config objects to their names. + * @return {Config[]} The flattened list of config objects. + */ +function processConfigList(configList, configNames) { + return configList.flatMap(config => processExtends(config, configNames)); +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * Helper function to define a config array. + * @param {ConfigWithExtendsArray} args The arguments to the function. + * @returns {Config[]} The config array. + */ +export function defineConfig(...args) { + const configNames = new WeakMap(); + const configs = []; + + if (args.length === 0) { + throw new TypeError("Expected one or more arguments."); + } + + // first flatten the list of configs and get the names + for (const { indexPath, value } of flatTraverse(args)) { + if (typeof value !== "object" || value === null) { + throw new TypeError( + `Expected an object but received ${String(value)}.`, + ); + } + + const config = /** @type {ConfigWithExtends} */ (value); + + // save config name for easy reference later + configNames.set(config, getConfigName(config, indexPath)); + configs.push(config); + } + + return processConfigList(configs, configNames); +} diff --git a/packages/config-helpers/src/index.js b/packages/config-helpers/src/index.js new file mode 100644 index 0000000..65b76a3 --- /dev/null +++ b/packages/config-helpers/src/index.js @@ -0,0 +1,5 @@ +/** + * @fileoverview Main entrypoint for the package. + */ + +export * from "./define-config.js"; diff --git a/packages/config-helpers/src/types.ts b/packages/config-helpers/src/types.ts new file mode 100644 index 0000000..084f7b2 --- /dev/null +++ b/packages/config-helpers/src/types.ts @@ -0,0 +1,31 @@ +/** + * @fileoverview Types for this package. + */ + +import type { Linter } from "eslint"; + +/** + * Infinite array type. + */ +export type InfiniteArray = T | InfiniteArray[]; + +/** + * The type of array element in the `extends` property after flattening. + */ +export type SimpleExtendsElement = string | Linter.Config; + +/** + * The type of array element in the `extends` property before flattening. + */ +export type ExtendsElement = + | SimpleExtendsElement + | InfiniteArray; + +/** + * Config with extends. Valid only inside of `defineConfig()`. + */ +export interface ConfigWithExtends extends Linter.Config { + extends?: ExtendsElement[]; +} + +export type ConfigWithExtendsArray = InfiniteArray[]; diff --git a/packages/config-helpers/tests/define-config.test.js b/packages/config-helpers/tests/define-config.test.js new file mode 100644 index 0000000..0c93cce --- /dev/null +++ b/packages/config-helpers/tests/define-config.test.js @@ -0,0 +1,1471 @@ +/** + * @fileoverview Tests for defineConfig helper + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { defineConfig } from "../src/define-config.js"; +import assert from "node:assert"; + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("defineConfig()", () => { + describe("extends", () => { + describe("extending objects", () => { + it("should extend two config objects without files", () => { + const config = defineConfig({ + extends: [ + { rules: { "no-console": "error" } }, + { rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0]", + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1]", + rules: { "no-alert": "error" }, + }, + { rules: { "no-debugger": "error" } }, + ]); + }); + + it("should extend two config objects with names", () => { + const config = defineConfig({ + name: "Base Config", + extends: [ + { name: "Console", rules: { "no-console": "error" } }, + { name: "Alert", rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base Config > Console", + rules: { "no-console": "error" }, + }, + { + name: "Base Config > Alert", + rules: { "no-alert": "error" }, + }, + { name: "Base Config", rules: { "no-debugger": "error" } }, + ]); + }); + + it("should extend two config objects with files", () => { + const config = defineConfig({ + files: ["*.js"], + extends: [ + { rules: { "no-console": "error" } }, + { rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0]", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1]", + files: ["*.js"], + rules: { "no-alert": "error" }, + }, + { files: ["*.js"], rules: { "no-debugger": "error" } }, + ]); + }); + + it("should extend two config objects with files and ignores", () => { + const config = defineConfig({ + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + extends: [ + { name: "Ext1", rules: { "no-console": "error" } }, + { name: "Ext2", rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > Ext1", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > Ext2", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend two config objects with files and ignores in all configs", () => { + const config = defineConfig({ + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + extends: [ + { + name: "Ext1", + files: ["*.jsx"], + ignores: ["bar.js"], + rules: { "no-console": "error" }, + }, + { + name: "Ext2", + files: ["foo*.js"], + ignores: ["baz.js"], + rules: { "no-alert": "error" }, + }, + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > Ext1", + files: [["*.js", "*.jsx"]], + ignores: ["foo.js", "bar.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > Ext2", + files: [["*.js", "foo*.js"]], + ignores: ["foo.js", "baz.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend two config objects with files and ignores and multiple configs passed in as an array", () => { + const config = defineConfig([ + { + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + extends: [ + { name: "Ext1", rules: { "no-console": "error" } }, + { name: "Ext2", rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }, + { + name: "Base 2", + files: ["*.ts"], + ignores: ["bar.js"], + extends: [ + { name: "Ext3", rules: { "no-console": "error" } }, + { name: "Ext4", rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }, + ]); + + assert.deepStrictEqual(config, [ + { + name: "Base > Ext1", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > Ext2", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base 2 > Ext3", + files: ["*.ts"], + ignores: ["bar.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base 2 > Ext4", + files: ["*.ts"], + ignores: ["bar.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base 2", + files: ["*.ts"], + ignores: ["bar.js"], + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend two config objects with files and ignores and multiple configs passed in as arguments", () => { + const config = defineConfig( + { + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + extends: [ + { name: "Ext1", rules: { "no-console": "error" } }, + { name: "Ext2", rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }, + { + name: "Base 2", + files: ["*.ts"], + ignores: ["bar.js"], + extends: [ + { name: "Ext3", rules: { "no-console": "error" } }, + { name: "Ext4", rules: { "no-alert": "error" } }, + ], + rules: { + "no-debugger": "error", + }, + }, + ); + + assert.deepStrictEqual(config, [ + { + name: "Base > Ext1", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > Ext2", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base", + files: ["*.js"], + ignores: ["foo.js"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base 2 > Ext3", + files: ["*.ts"], + ignores: ["bar.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base 2 > Ext4", + files: ["*.ts"], + ignores: ["bar.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base 2", + files: ["*.ts"], + ignores: ["bar.js"], + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend two config objects that are global ignores", () => { + const config = defineConfig({ + files: ["*.js"], + extends: [{ ignores: ["foo.js"] }, { ignores: ["bar.js"] }], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0]", + ignores: ["foo.js"], + }, + { + name: "UserConfig[0] > ExtendedConfig[1]", + ignores: ["bar.js"], + }, + { + files: ["*.js"], + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should not create a global ignores when an extended config doesn't have `ignores`", () => { + const config = defineConfig({ + name: "Base", + ignores: ["foo.js"], + extends: [ + { name: "Ext1", rules: { "no-console": "error" } }, + {}, + ], + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > Ext1", + ignores: ["foo.js"], + rules: { "no-console": "error" }, + }, + // should not create a global ignores + { + name: "Base > ExtendedConfig[1]", + }, + ]); + }); + + it("should omit base config when it only has ignores", () => { + const config = defineConfig({ + ignores: ["test/*.js"], + extends: [{ rules: { "no-console": "error" } }], + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0]", + ignores: ["test/*.js"], + rules: { "no-console": "error" }, + }, + ]); + }); + + it("should extend a three-dimensional array of configs", () => { + const config = defineConfig({ + extends: [ + [[{ rules: { "no-console": "error" } }]], + [[{ rules: { "no-alert": "error" } }]], + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0][0][0]", + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1][0][0]", + rules: { "no-alert": "error" }, + }, + { rules: { "no-debugger": "error" } }, + ]); + }); + }); + + describe("extending arrays", () => { + it("should extend two config arrays without files", () => { + const config = defineConfig({ + extends: [ + [{ rules: { "no-console": "error" } }], + [{ rules: { "no-alert": "error" } }], + ], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0][0]", + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1][0]", + rules: { "no-alert": "error" }, + }, + { rules: { "no-debugger": "error" } }, + ]); + }); + + it("should extend two config arrays each with two elements and files in base config", () => { + const config = defineConfig({ + files: ["*.js"], + extends: [ + [ + { rules: { "no-console": "error" } }, + { rules: { "no-debugger": "error" } }, + ], + [ + { rules: { "no-alert": "error" } }, + { + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + ], + ], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0][0]", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[0][1]", + files: ["*.js"], + rules: { "no-debugger": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1][0]", + files: ["*.js"], + rules: { "no-alert": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1][1]", + files: ["*.js"], + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + { files: ["*.js"], rules: { "no-unreachable": "error" } }, + ]); + }); + + it("should extend two config arrays each with two elements and names, and files in base config", () => { + const config = defineConfig({ + name: "Base", + files: ["*.js"], + extends: [ + [ + { name: "Ext1", rules: { "no-console": "error" } }, + { name: "Ext2", rules: { "no-debugger": "error" } }, + ], + [ + { name: "Ext3", rules: { "no-alert": "error" } }, + { + name: "Ext4", + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + ], + ], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > Ext1", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > Ext2", + files: ["*.js"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base > Ext3", + files: ["*.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base > Ext4", + files: ["*.js"], + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + { + name: "Base", + files: ["*.js"], + rules: { "no-unreachable": "error" }, + }, + ]); + }); + }); + + describe("extending strings", () => { + it("should extend two configs by string names", () => { + const test1Plugin = { + configs: { + config1: { + rules: { "no-console": "error" }, + }, + }, + }; + + const test2Plugin = { + configs: { + config2: { + rules: { "no-alert": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: test1Plugin, + test2: test2Plugin, + }, + extends: ["test1/config1", "test2/config2"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > test2/config2", + rules: { "no-alert": "error" }, + }, + { + plugins: { test1: test1Plugin, test2: test2Plugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend two array configs by string names", () => { + const test1Plugin = { + configs: { + config1: [ + { rules: { "no-console": "error" } }, + { rules: { "no-debugger": "error" } }, + ], + }, + }; + + const test2Plugin = { + configs: { + config2: [ + { name: "Ext3", rules: { "no-alert": "error" } }, + { + name: "Ext4", + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + ], + }, + }; + + const config = defineConfig({ + name: "Base", + files: ["*.js"], + plugins: { + test1: test1Plugin, + test2: test2Plugin, + }, + extends: ["test1/config1", "test2/config2"], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > test1/config1[0]", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > test1/config1[1]", + files: ["*.js"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base > test2/config2[0]", + files: ["*.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base > test2/config2[1]", + files: ["*.js"], + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + { + name: "Base", + plugins: { test1: test1Plugin, test2: test2Plugin }, + files: ["*.js"], + rules: { "no-unreachable": "error" }, + }, + ]); + }); + + it("should extend a three-dimensional array of configs by string names", () => { + const test1Plugin = { + configs: { + config1: [ + [{ rules: { "no-console": "error" } }], + [{ rules: { "no-debugger": "error" } }], + ], + }, + }; + + const test2Plugin = { + configs: { + config2: [ + [{ rules: { "no-alert": "error" } }], + [ + { + rules: { + "no-warning-comments": [ + "error", + { + terms: ["todo"], + location: "start", + }, + ], + }, + }, + ], + ], + }, + }; + + const config = defineConfig({ + plugins: { + test1: test1Plugin, + test2: test2Plugin, + }, + extends: ["test1/config1", "test2/config2"], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0][0][0]", + rules: { "no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[0][1][0]", + rules: { "no-debugger": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1][0][0]", + rules: { "no-alert": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[1][1][0]", + rules: { + "no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + { + plugins: { + test1: test1Plugin, + test2: test2Plugin, + }, + rules: { "no-unreachable": "error" }, + }, + ]); + }); + + it("should throw an error when a plugin is not found", () => { + assert.throws(() => { + defineConfig({ + extends: ["test1/config1"], + rules: { + "no-debugger": "error", + }, + }); + }, /Plugin "test1" not found\./u); + }); + + it("should throw an error when a plugin config is not found", () => { + const testPlugin = { + configs: { + config1: { + rules: { "no-console": "error" }, + }, + }, + }; + + assert.throws(() => { + defineConfig({ + plugins: { + test: testPlugin, + }, + extends: ["test/config2"], + rules: { + "no-debugger": "error", + }, + }); + }, /Plugin config "config2" not found in plugin "test"\./u); + }); + + it("should throw an error when a plugin config is in eslintrc format", () => { + const testPlugin = { + configs: { + config1: { + root: true, + rules: { "no-console": "error" }, + }, + }, + }; + + assert.throws(() => { + defineConfig({ + plugins: { + test: testPlugin, + }, + extends: ["test/config1"], + rules: { + "no-debugger": "error", + }, + }); + }, /Plugin config "test\/config1" is an eslintrc config and cannot be used in this context\./u); + }); + }); + + describe("extending mixed types", () => { + it("should extend configs using mix of strings, objects and arrays", () => { + const testPlugin = { + configs: { + recommended: { + rules: { "no-console": "error" }, + }, + }, + }; + + const config = defineConfig({ + name: "Base", + files: ["*.js"], + plugins: { test: testPlugin }, + extends: [ + "test/recommended", + { name: "Object", rules: { "no-alert": "error" } }, + [ + { rules: { "no-debugger": "error" } }, + { + name: "ArrayConfig", + rules: { "no-eval": "error" }, + }, + ], + ], + rules: { + "no-var": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > test/recommended", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base > Object", + files: ["*.js"], + rules: { "no-alert": "error" }, + }, + { + name: "Base > ExtendedConfig[2][0]", + files: ["*.js"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base > ArrayConfig", + files: ["*.js"], + rules: { "no-eval": "error" }, + }, + { + name: "Base", + files: ["*.js"], + plugins: { test: testPlugin }, + rules: { "no-var": "error" }, + }, + ]); + }); + }); + + describe("package namespace", () => { + it("should extend one config by string names when plugin has a namespace", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: { + rules: { "test/no-console": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + rules: { "test1/no-console": "error" }, + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend one config with complex rule name by string names when plugin has a namespace", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: { + rules: { "test/no-console/foo": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + rules: { "test1/no-console/foo": "error" }, + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend one config with processor by string names when plugin has a namespace", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: { + processor: "test/processor", + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + processor: "test1/processor", + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend a config array by string names when plugin has a namespace", () => { + const test1Plugin = { + meta: { + namespace: "testx", + }, + configs: { + config1: [ + { rules: { "testx/no-console": "error" } }, + { rules: { "testx/no-debugger": "error" } }, + ], + }, + }; + + const test2Plugin = { + meta: { + namespace: "testy", + }, + configs: { + config2: [ + { + name: "Ext3", + rules: { "testy/no-alert": "error" }, + }, + { + name: "Ext4", + rules: { + "testy/no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + ], + }, + }; + + const config = defineConfig({ + name: "Base", + files: ["*.js"], + plugins: { + test1: test1Plugin, + test2: test2Plugin, + }, + extends: ["test1/config1", "test2/config2"], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "Base > test1/config1[0]", + files: ["*.js"], + rules: { "test1/no-console": "error" }, + }, + { + name: "Base > test1/config1[1]", + files: ["*.js"], + rules: { "test1/no-debugger": "error" }, + }, + { + name: "Base > test2/config2[0]", + files: ["*.js"], + rules: { "test2/no-alert": "error" }, + }, + { + name: "Base > test2/config2[1]", + files: ["*.js"], + rules: { + "test2/no-warning-comments": [ + "error", + { terms: ["todo"], location: "start" }, + ], + }, + }, + { + name: "Base", + plugins: { test1: test1Plugin, test2: test2Plugin }, + files: ["*.js"], + rules: { "no-unreachable": "error" }, + }, + ]); + }); + + it("should extend one config with language by string names when plugin has a namespace", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: { + language: "test/typescript", + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + language: "test1/typescript", + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should not modify language when plugin has no namespace", () => { + const testPlugin = { + configs: { + config1: { + language: "test/typescript", + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + language: "test/typescript", + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should properly find a config with two slashes in the name", () => { + const testPlugin = { + configs: { + "config1/config2": { + rules: { "no-console": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1/config2"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1/config2", + rules: { "no-console": "error" }, + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should properly find a config with @ in the name", () => { + const testPlugin = { + configs: { + config1: { + rules: { "no-console": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + "@test1/plugin": testPlugin, + }, + extends: ["@test1/plugin/config1"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > @test1/plugin/config1", + rules: { "no-console": "error" }, + }, + { + plugins: { "@test1/plugin": testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should properly find a config with @ and two slashes in the name", () => { + const testPlugin = { + configs: { + "config1/example": { + rules: { "no-console": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + "@test1/plugin": testPlugin, + }, + extends: ["@test1/plugin/config1/example"], + rules: { + "no-debugger": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > @test1/plugin/config1/example", + rules: { "no-console": "error" }, + }, + { + plugins: { "@test1/plugin": testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should preserve rules that don't start with the plugin namespace", () => { + const testPlugin = { + configs: { + config1: { + rules: { "foo/no-console": "error" }, + }, + config2: { + rules: { "no-alert": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1", "test1/config2"], + rules: { + "no-debugger": "error", + "no-alert": "warn", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + rules: { "foo/no-console": "error" }, + }, + { + name: "UserConfig[0] > test1/config2", + rules: { "no-alert": "error" }, + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error", "no-alert": "warn" }, + }, + ]); + }); + + it("should extend objects with multiple plugin rules and maintain all rules", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: { + plugins: { x: {} }, + rules: { "x/no-console": "error" }, + }, + config2: { + plugins: { y: {} }, + rules: { "y/no-alert": "error" }, + }, + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1", "test1/config2"], + rules: { + "no-debugger": "error", + }, + }); + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > test1/config1", + plugins: { x: {} }, + rules: { "x/no-console": "error" }, + }, + { + name: "UserConfig[0] > test1/config2", + plugins: { y: {} }, + rules: { "y/no-alert": "error" }, + }, + { + plugins: { test1: testPlugin }, + rules: { "no-debugger": "error" }, + }, + ]); + }); + + it("should extend a three-dimensional array of configs by string names with plugin namespace replacement", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: [ + [{ rules: { "test/no-console": "error" } }], + [{ rules: { "test/no-debugger": "error" } }], + ], + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0][0][0]", + rules: { "test1/no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[0][1][0]", + rules: { "test1/no-debugger": "error" }, + }, + { + plugins: { + test1: testPlugin, + }, + rules: { "no-unreachable": "error" }, + }, + ]); + }); + + it("should extend a multi-dimensional array of configs by string names with plugin namespace replacement", () => { + const testPlugin = { + meta: { + namespace: "test", + }, + configs: { + config1: [ + [{ rules: { "test/no-console": "error" } }], + [ + { rules: { "test/no-debugger": "error" } }, + [{ rules: { "test/no-alert": "error" } }], + ], + ], + }, + }; + + const config = defineConfig({ + plugins: { + test1: testPlugin, + }, + extends: ["test1/config1"], + rules: { + "no-unreachable": "error", + }, + }); + + assert.deepStrictEqual(config, [ + { + name: "UserConfig[0] > ExtendedConfig[0][0][0]", + rules: { "test1/no-console": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[0][1][0]", + rules: { "test1/no-debugger": "error" }, + }, + { + name: "UserConfig[0] > ExtendedConfig[0][1][1][0]", + rules: { "test1/no-alert": "error" }, + }, + { + plugins: { + test1: testPlugin, + }, + rules: { "no-unreachable": "error" }, + }, + ]); + }); + }); + + describe("Errors", () => { + it("should throw an error when extends is not an array", () => { + assert.throws(() => { + defineConfig({ + extends: "test/recommended", + rules: { + "no-debugger": "error", + }, + }); + }, /The `extends` property must be an array\./u); + }); + + it("should throw an error when an extends element has an extends key", () => { + assert.throws(() => { + defineConfig({ + extends: [ + { + extends: ["test/recommended"], + rules: { "no-console": "error" }, + }, + ], + rules: { + "no-debugger": "error", + }, + }); + }, /Nested 'extends' is not allowed/u); + }); + }); + }); + + describe("multidimensional arrays", () => { + it("should handle a two-dimensional array of configs", () => { + const config = defineConfig([ + [ + { + name: "Base1", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base2", + files: ["*.ts"], + rules: { "no-alert": "error" }, + }, + ], + [ + { + name: "Base3", + files: ["*.jsx"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base4", + files: ["*.tsx"], + rules: { "no-unused-vars": "error" }, + }, + ], + ]); + + assert.deepStrictEqual(config, [ + { + name: "Base1", + files: ["*.js"], + rules: { "no-console": "error" }, + }, + { + name: "Base2", + files: ["*.ts"], + rules: { "no-alert": "error" }, + }, + { + name: "Base3", + files: ["*.jsx"], + rules: { "no-debugger": "error" }, + }, + { + name: "Base4", + files: ["*.tsx"], + rules: { "no-unused-vars": "error" }, + }, + ]); + }); + }); + + describe("Errors", () => { + it("should throw an error when null is passed to defineConfig", () => { + assert.throws(() => { + defineConfig(null); + }, /Expected an object but received null\./u); + }); + + it("should throw an error when no arguments are passed to defineConfig", () => { + assert.throws(() => { + defineConfig(); + }, /Expected one or more arguments\./u); + }); + }); +}); diff --git a/packages/config-helpers/tests/index.test.js b/packages/config-helpers/tests/index.test.js new file mode 100644 index 0000000..04fc43f --- /dev/null +++ b/packages/config-helpers/tests/index.test.js @@ -0,0 +1,20 @@ +/** + * @fileoverview Tests for package entrypoint + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import * as api from "../src/index.js"; +import assert from "node:assert"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe("index", () => { + it("should export defineConfig()", () => { + assert.strictEqual(typeof api.defineConfig, "function"); + }); +}); diff --git a/packages/config-helpers/tests/types/cjs-import.test.cts b/packages/config-helpers/tests/types/cjs-import.test.cts new file mode 100644 index 0000000..56113a9 --- /dev/null +++ b/packages/config-helpers/tests/types/cjs-import.test.cts @@ -0,0 +1,10 @@ +/** + * @fileoverview CommonJS type import test for Config Helpers package. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import "@eslint/config-helpers"; diff --git a/packages/config-helpers/tests/types/tsconfig.json b/packages/config-helpers/tests/types/tsconfig.json new file mode 100644 index 0000000..b3220a7 --- /dev/null +++ b/packages/config-helpers/tests/types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../..", + "strict": true + }, + "include": [".", "../../dist"] +} diff --git a/packages/config-helpers/tests/types/types.test.ts b/packages/config-helpers/tests/types/types.test.ts new file mode 100644 index 0000000..58019aa --- /dev/null +++ b/packages/config-helpers/tests/types/types.test.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Type tests for ESLint Config Helpers. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { defineConfig } from "@eslint/config-helpers"; + +//----------------------------------------------------------------------------- +// Type Checking +//----------------------------------------------------------------------------- + +defineConfig({}); +defineConfig({}, {}); +defineConfig([]); +defineConfig([], {}); +defineConfig([], []); +defineConfig([{}]); +defineConfig({ + extends: [], +}); + +defineConfig({ + rules: { + "no-console": "error", + }, +}); + +defineConfig({ + languageOptions: { + ecmaVersion: 2020, + }, +}); + +defineConfig({ + extends: [ + "js/recommended", + "react/recommended", + { rules: { "no-console": "off" } }, + ], +}); + +defineConfig({ + settings: { + react: { + version: "detect", + }, + }, +}); + +defineConfig({ + extends: [ + [ + [{ rules: { "no-alert": "warn" } }], + { rules: { "no-debugger": "error" } }, + ], + [ + { rules: { "no-eval": "error" } }, + { rules: { "no-implied-eval": "error" } }, + ], + ], +}); diff --git a/packages/config-helpers/tsconfig.esm.json b/packages/config-helpers/tsconfig.esm.json new file mode 100644 index 0000000..7ce1092 --- /dev/null +++ b/packages/config-helpers/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "files": ["dist/esm/index.js"], + "compilerOptions": { + "strict": false + } +} diff --git a/packages/config-helpers/tsconfig.json b/packages/config-helpers/tsconfig.json new file mode 100644 index 0000000..779639c --- /dev/null +++ b/packages/config-helpers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "files": ["src/index.js"], + "compilerOptions": { + "outDir": "dist/esm", + "strict": true + } +} diff --git a/release-please-config.json b/release-please-config.json index ad6fa87..9bbb200 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -23,7 +23,7 @@ } ] }, - "packages/core": { + "packages/config-helpers": { "release-type": "node", "extra-files": [ { @@ -33,7 +33,7 @@ } ] }, - "packages/object-schema": { + "packages/core": { "release-type": "node", "extra-files": [ { @@ -46,6 +46,16 @@ "packages/migrate-config": { "release-type": "node" }, + "packages/object-schema": { + "release-type": "node", + "extra-files": [ + { + "type": "json", + "path": "jsr.json", + "jsonpath": "$.version" + } + ] + }, "packages/plugin-kit": { "release-type": "node", "extra-files": [