diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d28d4bd..e90a2ad 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -50,20 +50,25 @@ jobs: #----------------------------------------------------------------------------- #----------------------------------------------------------------------------- - # @eslint/migrate-config + # @eslint/compat #----------------------------------------------------------------------------- - - name: Publish @eslint/migrate-config package to npm - run: npm publish -w packages/migrate-config - if: ${{ steps.release.outputs['packages/migrate-config--release_created'] }} + - name: Publish @eslint/compat package to npm + run: npm publish -w packages/compat + if: ${{ steps.release.outputs['packages/compat--release_created'] }} env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - # NOTE: No JSR package because JSR doesn't support CLIs + - name: Publish @eslint/compat package to JSR + run: | + npm run build --if-present + npx jsr publish + working-directory: packages/compat + if: ${{ steps.release.outputs['packages/compat--release_created'] }} - name: Tweet Release Announcement - run: npx @humanwhocodes/tweet "eslint/migrate-config v${{ steps.release.outputs['packages/migrate-config--major'] }}.${{ steps.release.outputs['packages/migrate-config--minor'] }}.${{ steps.release.outputs['packages/migrate-config--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/migrate-config--tag_name'] }}" - if: ${{ steps.release.outputs['packages/migrate-config--release_created'] }} + run: npx @humanwhocodes/tweet "eslint/compat v${{ steps.release.outputs['packages/compat--major'] }}.${{ steps.release.outputs['packages/compat--minor'] }}.${{ steps.release.outputs['packages/compat--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/compat--tag_name'] }}" + if: ${{ steps.release.outputs['packages/compat--release_created'] }} env: TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} @@ -71,32 +76,27 @@ jobs: TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} - name: Toot Release Announcement - run: npx @humanwhocodes/toot "eslint/migrate-config v${{ steps.release.outputs['packages/migrate-config--major'] }}.${{ steps.release.outputs['packages/migrate-config--minor'] }}.${{ steps.release.outputs['packages/migrate-config--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/migrate-config--tag_name'] }}" - if: ${{ steps.release.outputs['packages/migrate-config--release_created'] }} + run: npx @humanwhocodes/toot "eslint/compat v${{ steps.release.outputs['packages/compat--major'] }}.${{ steps.release.outputs['packages/compat--minor'] }}.${{ steps.release.outputs['packages/compat--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/compat--tag_name'] }}" + if: ${{ steps.release.outputs['packages/compat--release_created'] }} env: MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} MASTODON_HOST: ${{ secrets.MASTODON_HOST }} #----------------------------------------------------------------------------- - # @eslint/compat + # @eslint/migrate-config #----------------------------------------------------------------------------- - - name: Publish @eslint/compat package to npm - run: npm publish -w packages/compat - if: ${{ steps.release.outputs['packages/compat--release_created'] }} + - name: Publish @eslint/migrate-config package to npm + run: npm publish -w packages/migrate-config + if: ${{ steps.release.outputs['packages/migrate-config--release_created'] }} env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - - name: Publish @eslint/compat package to JSR - run: | - npm run build --if-present - npx jsr publish - working-directory: packages/compat - if: ${{ steps.release.outputs['packages/compat--release_created'] }} + # NOTE: No JSR package because JSR doesn't support CLIs - name: Tweet Release Announcement - run: npx @humanwhocodes/tweet "eslint/compat v${{ steps.release.outputs['packages/compat--major'] }}.${{ steps.release.outputs['packages/compat--minor'] }}.${{ steps.release.outputs['packages/compat--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/compat--tag_name'] }}" - if: ${{ steps.release.outputs['packages/compat--release_created'] }} + run: npx @humanwhocodes/tweet "eslint/migrate-config v${{ steps.release.outputs['packages/migrate-config--major'] }}.${{ steps.release.outputs['packages/migrate-config--minor'] }}.${{ steps.release.outputs['packages/migrate-config--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/migrate-config--tag_name'] }}" + if: ${{ steps.release.outputs['packages/migrate-config--release_created'] }} env: TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} @@ -104,8 +104,8 @@ jobs: TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} - name: Toot Release Announcement - run: npx @humanwhocodes/toot "eslint/compat v${{ steps.release.outputs['packages/compat--major'] }}.${{ steps.release.outputs['packages/compat--minor'] }}.${{ steps.release.outputs['packages/compat--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/compat--tag_name'] }}" - if: ${{ steps.release.outputs['packages/compat--release_created'] }} + run: npx @humanwhocodes/toot "eslint/migrate-config v${{ steps.release.outputs['packages/migrate-config--major'] }}.${{ steps.release.outputs['packages/migrate-config--minor'] }}.${{ steps.release.outputs['packages/migrate-config--patch'] }} has been released!\n\n${{ github.event.repository.html_url }}/releases/tag/${{ steps.release.outputs['packages/migrate-config--tag_name'] }}" + if: ${{ steps.release.outputs['packages/migrate-config--release_created'] }} env: MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} MASTODON_HOST: ${{ secrets.MASTODON_HOST }} diff --git a/packages/compat/README.md b/packages/compat/README.md index 5b96734..c6b0ec0 100644 --- a/packages/compat/README.md +++ b/packages/compat/README.md @@ -33,6 +33,7 @@ This package exports the following functions in both ESM and CommonJS format: - `fixupRule(rule)` - wraps the given rule in a compatibility layer and returns the result - `fixupPluginRules(plugin)` - wraps each rule in the given plugin using `fixupRule()` and returns a new object that represents the plugin with the fixed-up rules - `fixupConfigRules(configs)` - wraps all plugins found in an array of config objects using `fixupPluginRules()` +- `includeIgnoreFile(path)` - reads an ignore file (like `.gitignore`) and converts the patterns into the correct format for the config file ### Fixing Rules @@ -142,6 +143,46 @@ module.exports = [ ]; ``` +### Including Ignore Files + +If you were using an alternate ignore file in ESLint v8.x, such as using `--ignore-path .gitignore` on the command line, you can include those patterns programmatically in your config file using the `includeIgnoreFile()` function. For example: + +```js +// eslint.config.js - ESM example +import { includeIgnoreFile } from "@eslint/compat"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const gitignorePath = path.resolve(__dirname, ".gitignore"); + +export default [ + includeIgnoreFile(gitignorePath), + { + // your overrides + }, +]; +``` + +Or in CommonJS: + +```js +// eslint.config.js - CommonJS example +const { includeIgnoreFile } = require("@eslint/compat"); +const path = require("node:path"); +const gitignorePath = path.resolve(__dirname, ".gitignore"); + +module.exports = [ + includeIgnoreFile(gitignorePath), + { + // your overrides + }, +]; +``` + +**Limitation:** This works without modification when the ignore file is in the same directory as your config file. If the ignore file is in a different directory, you may need to modify the patterns manually. + ## License Apache 2.0 diff --git a/packages/compat/src/fixup-rules.js b/packages/compat/src/fixup-rules.js index e3de1af..5b58e84 100644 --- a/packages/compat/src/fixup-rules.js +++ b/packages/compat/src/fixup-rules.js @@ -1,11 +1,8 @@ /** - * @fileoverview Object Schema + * @filedescription Functions to fix up rules to provide missing methods on the `context` object. + * @author Nicholas C. Zakas */ -//----------------------------------------------------------------------------- -// Imports -//----------------------------------------------------------------------------- - //----------------------------------------------------------------------------- // Types //----------------------------------------------------------------------------- diff --git a/packages/compat/src/ignore-file.js b/packages/compat/src/ignore-file.js new file mode 100644 index 0000000..391dcb8 --- /dev/null +++ b/packages/compat/src/ignore-file.js @@ -0,0 +1,74 @@ +/** + * @fileoverview Ignore file utilities for the compat package. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import fs from "node:fs"; +import path from "node:path"; + +//----------------------------------------------------------------------------- +// Types +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Linter.FlatConfig} FlatConfig */ + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * Converts an ESLint ignore pattern to a minimatch pattern. + * @param {string} pattern The .eslintignore or .gitignore pattern to convert. + * @returns {string} The converted pattern. + */ +export function convertIgnorePatternToMinimatch(pattern) { + const isNegated = pattern.startsWith("!"); + const negatedPrefix = isNegated ? "!" : ""; + const patternToTest = (isNegated ? pattern.slice(1) : pattern).trimEnd(); + + // special cases + if (["", "**", "/**", "**/"].includes(patternToTest)) { + return `${negatedPrefix}${patternToTest}`; + } + + const firstIndexOfSlash = patternToTest.indexOf("/"); + + const matchEverywherePrefix = + firstIndexOfSlash < 0 || firstIndexOfSlash === patternToTest.length - 1 + ? "**/" + : ""; + + const patternWithoutLeadingSlash = + firstIndexOfSlash === 0 ? patternToTest.slice(1) : patternToTest; + + const matchInsideSuffix = patternToTest.endsWith("/**") ? "/*" : ""; + + return `${negatedPrefix}${matchEverywherePrefix}${patternWithoutLeadingSlash}${matchInsideSuffix}`; +} + +/** + * Reads an ignore file and returns an object with the ignore patterns. + * @param {string} ignoreFilePath The absolute path to the ignore file. + * @returns {FlatConfig} An object with an `ignores` property that is an array of ignore patterns. + * @throws {Error} If the ignore file path is not an absolute path. + */ +export function includeIgnoreFile(ignoreFilePath) { + if (!path.isAbsolute(ignoreFilePath)) { + throw new Error("The ignore file location must be an absolute path."); + } + + const ignoreFile = fs.readFileSync(ignoreFilePath, "utf8"); + const lines = ignoreFile.split(/\r?\n/u); + + return { + name: "Imported .gitignore patterns", + ignores: lines + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")) + .map(convertIgnorePatternToMinimatch), + }; +} diff --git a/packages/compat/src/index.js b/packages/compat/src/index.js index 7512c72..c8f2485 100644 --- a/packages/compat/src/index.js +++ b/packages/compat/src/index.js @@ -3,3 +3,4 @@ */ export * from "./fixup-rules.js"; +export * from "./ignore-file.js"; diff --git a/packages/compat/tests/fixtures/ignore-files/gitignore1.txt b/packages/compat/tests/fixtures/ignore-files/gitignore1.txt new file mode 100644 index 0000000..f6b5f57 --- /dev/null +++ b/packages/compat/tests/fixtures/ignore-files/gitignore1.txt @@ -0,0 +1,17 @@ +# Node.js +node_modules +!/fixtures/node_modules +/dist + +# Logs +*.log + +# Gatsby files +.cache/ + +# vuepress build output +.vuepress/dist + +# other +*/foo.js +dir/** diff --git a/packages/compat/tests/ignore-file.js b/packages/compat/tests/ignore-file.js new file mode 100644 index 0000000..50318e7 --- /dev/null +++ b/packages/compat/tests/ignore-file.js @@ -0,0 +1,79 @@ +/** + * @filedescription Fixup tests + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import assert from "node:assert"; +import { + includeIgnoreFile, + convertIgnorePatternToMinimatch, +} from "../src/ignore-file.js"; +import { fileURLToPath } from "node:url"; + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("@eslint/compat", () => { + describe("convertIgnorePatternToMinimatch", () => { + const tests = [ + ["", ""], + ["**", "**"], + ["/**", "/**"], + ["**/", "**/"], + ["src/", "**/src/"], + ["src", "**/src"], + ["src/**", "src/**/*"], + ["!src/", "!**/src/"], + ["!src", "!**/src"], + ["!src/**", "!src/**/*"], + ["*/foo.js", "*/foo.js"], + ["*/foo.js/", "*/foo.js/"], + ]; + + tests.forEach(([pattern, expected]) => { + it(`should convert "${pattern}" to "${expected}"`, () => { + assert.strictEqual( + convertIgnorePatternToMinimatch(pattern), + expected, + ); + }); + }); + }); + + describe("includeIgnoreFile", () => { + it("should throw an error when a relative path is passed", () => { + const ignoreFilePath = + "../tests/fixtures/ignore-files/gitignore1.txt"; + assert.throws(() => { + includeIgnoreFile(ignoreFilePath); + }, /The ignore file location must be an absolute path./u); + }); + + it("should return an object with an `ignores` property", () => { + const ignoreFilePath = fileURLToPath( + new URL( + "../tests/fixtures/ignore-files/gitignore1.txt", + import.meta.url, + ), + ); + const result = includeIgnoreFile(ignoreFilePath); + assert.deepStrictEqual(result, { + name: "Imported .gitignore patterns", + ignores: [ + "**/node_modules", + "!fixtures/node_modules", + "dist", + "**/*.log", + "**/.cache/", + ".vuepress/dist", + "*/foo.js", + "dir/**/*", + ], + }); + }); + }); +}); diff --git a/packages/migrate-config/package.json b/packages/migrate-config/package.json index cac9997..42ca792 100644 --- a/packages/migrate-config/package.json +++ b/packages/migrate-config/package.json @@ -46,6 +46,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "dependencies": { + "@eslint/compat": "^1.0.3", "@eslint/eslintrc": "^3.1.0", "camelcase": "^8.0.0", "recast": "^0.23.7" diff --git a/packages/migrate-config/src/migrate-config.js b/packages/migrate-config/src/migrate-config.js index 7554654..cd5ebe0 100644 --- a/packages/migrate-config/src/migrate-config.js +++ b/packages/migrate-config/src/migrate-config.js @@ -11,6 +11,7 @@ import * as recast from "recast"; import { Legacy } from "@eslint/eslintrc"; import camelCase from "camelcase"; import pluginsNeedingCompat from "./compat-plugins.js"; +import { convertIgnorePatternToMinimatch } from "@eslint/compat"; //----------------------------------------------------------------------------- // Types @@ -121,36 +122,6 @@ function getParserVariableName(parser) { return "parser"; } -/** - * Converts an ESLint ignore pattern to a minimatch pattern. - * @param {string} pattern The .eslintignore pattern to convert. - * @returns {string} The converted pattern. - */ -function convertESLintIgnoreToMinimatch(pattern) { - const isNegated = pattern.startsWith("!"); - const negatedPrefix = isNegated ? "!" : ""; - const patternToTest = (isNegated ? pattern.slice(1) : pattern).trimEnd(); - - // special cases - if (["", "**", "/**", "**/"].includes(patternToTest)) { - return `${negatedPrefix}${patternToTest}`; - } - - const firstIndexOfSlash = patternToTest.indexOf("/"); - - const matchEverywherePrefix = - firstIndexOfSlash < 0 || firstIndexOfSlash === patternToTest.length - 1 - ? "**/" - : ""; - - const patternWithoutLeadingSlash = - firstIndexOfSlash === 0 ? patternToTest.slice(1) : patternToTest; - - const matchInsideSuffix = patternToTest.endsWith("/**") ? "/*" : ""; - - return `${negatedPrefix}${matchEverywherePrefix}${patternWithoutLeadingSlash}${matchInsideSuffix}`; -} - // cache for plugins needing compat const pluginsNeedingCompatCache = new Set(pluginsNeedingCompat); @@ -609,7 +580,7 @@ function createGlobalIgnores(config) { : [config.ignorePatterns]; const ignorePatternsArray = b.arrayExpression( ignorePatterns.map(pattern => - b.literal(convertESLintIgnoreToMinimatch(pattern)), + b.literal(convertIgnorePatternToMinimatch(pattern)), ), ); return b.objectExpression([