diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/code-pushup.config.ts b/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/code-pushup.config.ts new file mode 100644 index 000000000..87866b4f0 --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/code-pushup.config.ts @@ -0,0 +1,5 @@ +import eslintPlugin from '@code-pushup/eslint-plugin'; + +export default { + plugins: [await eslintPlugin({ patterns: ['src/*.js'] })], +}; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/eslint.config.cjs b/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/eslint.config.cjs new file mode 100644 index 000000000..4bb99249e --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/eslint.config.cjs @@ -0,0 +1,13 @@ +/** @type {import('eslint').Linter.FlatConfig[]} */ +module.exports = [ + { + ignores: ['code-pushup.config.ts'], + }, + { + rules: { + eqeqeq: 'error', + 'max-lines': ['warn', 100], + 'no-unused-vars': 'warn', + }, + }, +]; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/src/index.js b/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/src/index.js new file mode 100644 index 000000000..39665c6ec --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/flat-config/src/index.js @@ -0,0 +1,9 @@ +function unusedFn() { + return '42'; +} + +module.exports = function orwell() { + if (2 + 2 == 5) { + console.log(1984); + } +}; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/old-version/.eslintrc.json b/e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/.eslintrc.json similarity index 100% rename from e2e/plugin-eslint-e2e/mocks/fixtures/old-version/.eslintrc.json rename to e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/.eslintrc.json diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/code-pushup.config.ts b/e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/code-pushup.config.ts new file mode 100644 index 000000000..70e710ea7 --- /dev/null +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/code-pushup.config.ts @@ -0,0 +1,10 @@ +import eslintPlugin from '@code-pushup/eslint-plugin'; + +export default { + plugins: [ + await eslintPlugin({ + eslintrc: '.eslintrc.json', + patterns: ['src/*.js'], + }), + ], +}; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/old-version/src/index.js b/e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/src/index.js similarity index 99% rename from e2e/plugin-eslint-e2e/mocks/fixtures/old-version/src/index.js rename to e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/src/index.js index 4710a3767..1e48d9724 100644 --- a/e2e/plugin-eslint-e2e/mocks/fixtures/old-version/src/index.js +++ b/e2e/plugin-eslint-e2e/mocks/fixtures/legacy-config/src/index.js @@ -1,7 +1,6 @@ function unusedFn() { return '42'; } - module.exports = function consoleLog() { console.log('No console.log()!'); }; diff --git a/e2e/plugin-eslint-e2e/mocks/fixtures/old-version/code-pushup.config.ts b/e2e/plugin-eslint-e2e/mocks/fixtures/old-version/code-pushup.config.ts deleted file mode 100644 index 01feaf2ff..000000000 --- a/e2e/plugin-eslint-e2e/mocks/fixtures/old-version/code-pushup.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import eslintPlugin from '@code-pushup/eslint-plugin'; - -export default { - plugins: [ - await eslintPlugin({ - eslintrc: '.eslintrc.json', - patterns: ['src/*.js'], - }), - ], - categories: [ - { - slug: 'bug-prevention', - title: 'Bug prevention', - description: 'Lint rules that find **potential bugs** in your code.', - refs: [{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 }], - }, - { - slug: 'code-style', - title: 'Code style', - description: - 'Lint rules that promote **good practices** and consistency in your code.', - refs: [ - { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }, - ], - }, - ], -}; diff --git a/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index ea1b4434e..a7f23fd22 100644 --- a/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/plugin-eslint-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -1,35 +1,123 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`collect report with eslint-plugin NPM package > should run ESLint plugin and create report.json 1`] = ` +exports[`collect report with eslint-plugin NPM package > should run ESLint plugin for flat config and create report.json 1`] = ` { - "categories": [ + "packageName": "@code-pushup/core", + "plugins": [ { - "description": "Lint rules that find **potential bugs** in your code.", - "refs": [ + "audits": [ { - "plugin": "eslint", - "slug": "problems", - "type": "group", - "weight": 1, + "description": "ESLint rule **eqeqeq**.", + "details": { + "issues": [ + { + "message": "Expected '===' and instead saw '=='.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-eslint-e2e/flat-config/src/index.js", + "position": { + "endColumn": 15, + "endLine": 6, + "startColumn": 13, + "startLine": 6, + }, + }, + }, + ], + }, + "displayValue": "1 error", + "docsUrl": "https://eslint.org/docs/latest/rules/eqeqeq", + "score": 0, + "slug": "eqeqeq", + "title": "Require the use of \`===\` and \`!==\`", + "value": 1, + }, + { + "description": "ESLint rule **max-lines**. + +Custom options: + +\`\`\`json +100 +\`\`\`", + "details": { + "issues": [], + }, + "displayValue": "passed", + "docsUrl": "https://eslint.org/docs/latest/rules/max-lines", + "score": 1, + "slug": "max-lines-71b54366cb01f77b", + "title": "Enforce a maximum number of lines per file", + "value": 0, + }, + { + "description": "ESLint rule **no-unused-vars**.", + "details": { + "issues": [ + { + "message": "'unusedFn' is defined but never used.", + "severity": "warning", + "source": { + "file": "tmp/e2e/plugin-eslint-e2e/flat-config/src/index.js", + "position": { + "endColumn": 18, + "endLine": 1, + "startColumn": 10, + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "1 warning", + "docsUrl": "https://eslint.org/docs/latest/rules/no-unused-vars", + "score": 0, + "slug": "no-unused-vars", + "title": "Disallow unused variables", + "value": 1, }, ], - "slug": "bug-prevention", - "title": "Bug prevention", - }, - { - "description": "Lint rules that promote **good practices** and consistency in your code.", - "refs": [ + "description": "Official Code PushUp ESLint plugin", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/eslint-plugin", + "groups": [ { - "plugin": "eslint", + "description": "Code that either will cause an error or may cause confusing behavior. Developers should consider this a high priority to resolve.", + "refs": [ + { + "slug": "no-unused-vars", + "weight": 1, + }, + ], + "slug": "problems", + "title": "Problems", + }, + { + "description": "Something that could be done in a better way but no errors will occur if the code isn't changed.", + "refs": [ + { + "slug": "eqeqeq", + "weight": 1, + }, + { + "slug": "max-lines-71b54366cb01f77b", + "weight": 1, + }, + ], "slug": "suggestions", - "type": "group", - "weight": 1, + "title": "Suggestions", }, ], - "slug": "code-style", - "title": "Code style", + "icon": "eslint", + "packageName": "@code-pushup/eslint-plugin", + "slug": "eslint", + "title": "ESLint", }, ], +} +`; + +exports[`collect report with eslint-plugin NPM package > should run ESLint plugin for legacy config and create report.json 1`] = ` +{ "packageName": "@code-pushup/core", "plugins": [ { @@ -42,7 +130,7 @@ exports[`collect report with eslint-plugin NPM package > should run ESLint plugi "message": "'unusedFn' is defined but never used.", "severity": "error", "source": { - "file": "tmp/e2e/plugin-eslint-e2e/old-version/src/index.js", + "file": "tmp/e2e/plugin-eslint-e2e/legacy-config/src/index.js", "position": { "endColumn": 18, "endLine": 1, @@ -68,12 +156,12 @@ exports[`collect report with eslint-plugin NPM package > should run ESLint plugi "message": "Unexpected console statement.", "severity": "warning", "source": { - "file": "tmp/e2e/plugin-eslint-e2e/old-version/src/index.js", + "file": "tmp/e2e/plugin-eslint-e2e/legacy-config/src/index.js", "position": { "endColumn": 14, - "endLine": 6, + "endLine": 5, "startColumn": 3, - "startLine": 6, + "startLine": 5, }, }, }, diff --git a/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts b/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts index 784f54056..ef069bcc1 100644 --- a/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts +++ b/e2e/plugin-eslint-e2e/tests/collect.e2e.test.ts @@ -7,40 +7,61 @@ import { omitVariableReportData } from '@code-pushup/test-utils'; import { executeProcess, readJsonFile } from '@code-pushup/utils'; describe('collect report with eslint-plugin NPM package', () => { - const fixturesOldVersionDir = join( - 'e2e', - 'plugin-eslint-e2e', - 'mocks', - 'fixtures', - 'old-version', - ); + const fixturesDir = join('e2e', 'plugin-eslint-e2e', 'mocks', 'fixtures'); + const fixturesFlatConfigDir = join(fixturesDir, 'flat-config'); + const fixturesLegacyConfigDir = join(fixturesDir, 'legacy-config'); + const envRoot = join('tmp', 'e2e', 'plugin-eslint-e2e'); - const oldVersionDir = join(envRoot, 'old-version'); - const oldVersionOutputDir = join(oldVersionDir, '.code-pushup'); + const flatConfigDir = join(envRoot, 'flat-config'); + const legacyConfigDir = join(envRoot, 'legacy-config'); + const flatConfigOutputDir = join(flatConfigDir, '.code-pushup'); + const legacyConfigOutputDir = join(legacyConfigDir, '.code-pushup'); beforeAll(async () => { - await cp(fixturesOldVersionDir, oldVersionDir, { recursive: true }); + await cp(fixturesFlatConfigDir, flatConfigDir, { recursive: true }); + await cp(fixturesLegacyConfigDir, legacyConfigDir, { recursive: true }); }); afterAll(async () => { - await teardownTestFolder(oldVersionDir); + await teardownTestFolder(flatConfigDir); + await teardownTestFolder(legacyConfigDir); }); afterEach(async () => { - await teardownTestFolder(oldVersionOutputDir); + await teardownTestFolder(flatConfigOutputDir); + await teardownTestFolder(legacyConfigOutputDir); + }); + + it('should run ESLint plugin for flat config and create report.json', async () => { + const { code, stderr } = await executeProcess({ + command: 'npx', + args: ['@code-pushup/cli', 'collect', '--no-progress'], + cwd: flatConfigDir, + }); + + expect(code).toBe(0); + expect(stderr).toBe(''); + + const report = await readJsonFile(join(flatConfigOutputDir, 'report.json')); + + expect(() => reportSchema.parse(report)).not.toThrow(); + expect(omitVariableReportData(report as Report)).toMatchSnapshot(); }); - it('should run ESLint plugin and create report.json', async () => { + it('should run ESLint plugin for legacy config and create report.json', async () => { const { code, stderr } = await executeProcess({ command: 'npx', args: ['@code-pushup/cli', 'collect', '--no-progress'], - cwd: oldVersionDir, + cwd: legacyConfigDir, + env: { ...process.env, ESLINT_USE_FLAT_CONFIG: 'false' }, }); expect(code).toBe(0); expect(stderr).toBe(''); - const report = await readJsonFile(join(oldVersionOutputDir, 'report.json')); + const report = await readJsonFile( + join(legacyConfigOutputDir, 'report.json'), + ); expect(() => reportSchema.parse(report)).not.toThrow(); expect(omitVariableReportData(report as Report)).toMatchSnapshot(); diff --git a/packages/plugin-eslint/mocks/fixtures/todos-app/code-pushup.eslintrc.yml b/packages/plugin-eslint/mocks/fixtures/todos-app/code-pushup.eslintrc.yml new file mode 100644 index 000000000..f7da37f07 --- /dev/null +++ b/packages/plugin-eslint/mocks/fixtures/todos-app/code-pushup.eslintrc.yml @@ -0,0 +1,2 @@ +root: true +extends: '@code-pushup' diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index 112c9731d..886d8337a 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -42,11 +42,11 @@ "dependencies": { "@code-pushup/utils": "0.54.0", "@code-pushup/models": "0.54.0", - "eslint": "^8.46.0", "zod": "^3.22.4" }, "peerDependencies": { - "@nx/devkit": ">=17.0.0" + "@nx/devkit": ">=17.0.0", + "eslint": "^8.46.0 || ^9.0.0" }, "peerDependenciesMeta": { "@nx/devkit": { diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index aa14ccb12..2eb182437 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -1,5 +1,4 @@ -import type { ESLint } from 'eslint'; -import { type ZodType, z } from 'zod'; +import { z } from 'zod'; import { toArray } from '@code-pushup/utils'; const patternsSchema = z.union([z.string(), z.array(z.string()).min(1)], { @@ -7,15 +6,7 @@ const patternsSchema = z.union([z.string(), z.array(z.string()).min(1)], { 'Lint target files. May contain file paths, directory paths or glob patterns', }); -const eslintrcSchema = z.union( - [ - z.string({ description: 'Path to ESLint config file' }), - z.record(z.string(), z.unknown(), { - description: 'ESLint config object', - }) as ZodType, - ], - { description: 'ESLint config as file path or inline object' }, -); +const eslintrcSchema = z.string({ description: 'Path to ESLint config file' }); const eslintTargetObjectSchema = z.object({ eslintrc: eslintrcSchema.optional(), diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts b/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts index 9f4c5bf5c..f0b3bd403 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.integration.test.ts @@ -71,19 +71,6 @@ describe('eslintPlugin', () => { ); }); - it('should initialize ESLint plugin using inline config', async () => { - cwdSpy.mockReturnValue(thisDir); - const plugin = await eslintPlugin({ - eslintrc: { - extends: '@code-pushup', - }, - patterns: '**/*.ts', - }); - - expect(plugin.groups?.length).toBeGreaterThanOrEqual(3); - expect(plugin.audits.length).toBeGreaterThanOrEqual(200); - }); - it('should throw when invalid parameters provided', async () => { await expect( // @ts-expect-error simulating invalid non-TS config diff --git a/packages/plugin-eslint/src/lib/meta/groups.ts b/packages/plugin-eslint/src/lib/meta/groups.ts index 0b1c6af2a..534dee7f9 100644 --- a/packages/plugin-eslint/src/lib/meta/groups.ts +++ b/packages/plugin-eslint/src/lib/meta/groups.ts @@ -2,7 +2,7 @@ import type { Rule } from 'eslint'; import type { Group, GroupRef } from '@code-pushup/models'; import { objectToKeys, slugify } from '@code-pushup/utils'; import { ruleIdToSlug } from './hash'; -import { type RuleData, parseRuleId } from './rules'; +import { type RuleData, parseRuleId } from './parse'; type RuleType = NonNullable; diff --git a/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts b/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts index 8b1bbc1b0..006d477f4 100644 --- a/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts +++ b/packages/plugin-eslint/src/lib/meta/groups.unit.test.ts @@ -1,6 +1,6 @@ import type { Group } from '@code-pushup/models'; import { groupsFromRuleCategories, groupsFromRuleTypes } from './groups'; -import type { RuleData } from './rules'; +import type { RuleData } from './parse'; const eslintRules: RuleData[] = [ { diff --git a/packages/plugin-eslint/src/lib/meta/parse.ts b/packages/plugin-eslint/src/lib/meta/parse.ts new file mode 100644 index 000000000..31523a39c --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/parse.ts @@ -0,0 +1,40 @@ +import type { Linter, Rule } from 'eslint'; +import { toArray } from '@code-pushup/utils'; + +export type RuleData = { + ruleId: string; + meta: Rule.RuleMetaData; + options: unknown[] | undefined; +}; + +export function parseRuleId(ruleId: string): { plugin?: string; name: string } { + const i = ruleId.lastIndexOf('/'); + if (i < 0) { + return { name: ruleId }; + } + return { + plugin: ruleId.slice(0, i), + name: ruleId.slice(i + 1), + }; +} + +export function isRuleOff(entry: Linter.RuleEntry): boolean { + const level = Array.isArray(entry) ? entry[0] : entry; + + switch (level) { + case 0: + case 'off': + return true; + case 1: + case 2: + case 'warn': + case 'error': + return false; + } +} + +export function optionsFromRuleEntry( + entry: Linter.RuleEntry, +): unknown[] { + return toArray(entry).slice(1); +} diff --git a/packages/plugin-eslint/src/lib/meta/parse.unit.test.ts b/packages/plugin-eslint/src/lib/meta/parse.unit.test.ts new file mode 100644 index 000000000..6817fc3d2 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/parse.unit.test.ts @@ -0,0 +1,85 @@ +import type { Linter } from 'eslint'; +import { isRuleOff, optionsFromRuleEntry, parseRuleId } from './parse'; + +describe('parseRuleId', () => { + it.each([ + { + ruleId: 'prefer-const', + name: 'prefer-const', + }, + { + ruleId: 'sonarjs/no-identical-functions', + plugin: 'sonarjs', + name: 'no-identical-functions', + }, + { + ruleId: '@typescript-eslint/no-non-null-assertion', + plugin: '@typescript-eslint', + name: 'no-non-null-assertion', + }, + { + ruleId: 'no-secrets/no-secrets', + plugin: 'no-secrets', + name: 'no-secrets', + }, + { + ruleId: '@angular-eslint/template/no-negated-async', + plugin: '@angular-eslint/template', + name: 'no-negated-async', + }, + ])('$ruleId => name: $name, plugin: $plugin', ({ ruleId, name, plugin }) => { + expect(parseRuleId(ruleId)).toEqual({ name, plugin }); + }); +}); + +describe('isRuleOff', () => { + type TestCase = { entry: Linter.RuleEntry; expected: boolean }; + + it.each([ + { entry: 'off', expected: true }, + { entry: 'warn', expected: false }, + { entry: 'error', expected: false }, + ])( + 'should return $expected for string severity $entry', + ({ entry, expected }) => { + expect(isRuleOff(entry)).toBe(expected); + }, + ); + + it.each([ + { entry: 0, expected: true }, + { entry: 1, expected: false }, + { entry: 2, expected: false }, + ])( + 'should return $expected for numeric severity $entry', + ({ entry, expected }) => { + expect(isRuleOff(entry)).toBe(expected); + }, + ); + + it.each([ + { entry: [0], expected: true }, + { entry: ['off'], expected: true }, + { entry: ['warn', { max: 10 }], expected: false }, + { entry: [2, { ignore: /^_/ }], expected: false }, + ])( + 'should return $expected for array entry $entry', + ({ entry, expected }) => { + expect(isRuleOff(entry)).toBe(expected); + }, + ); +}); + +describe('optionsFromRuleEntry', () => { + it('should return options from array entry', () => { + expect(optionsFromRuleEntry(['warn', { max: 10 }])).toEqual([{ max: 10 }]); + }); + + it('should return empty options for non-array entry', () => { + expect(optionsFromRuleEntry('error')).toEqual([]); + }); + + it('should return empty options for array entry with severity only', () => { + expect(optionsFromRuleEntry(['warn'])).toEqual([]); + }); +}); diff --git a/packages/plugin-eslint/src/lib/meta/rules.ts b/packages/plugin-eslint/src/lib/meta/rules.ts index a5dd1ff70..839308085 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.ts @@ -1,112 +1,29 @@ -import type { ESLint, Linter, Rule } from 'eslint'; -import { distinct, toArray, ui } from '@code-pushup/utils'; import type { ESLintTarget } from '../config'; -import { setupESLint } from '../setup'; import { jsonHash } from './hash'; - -export type RuleData = { - ruleId: string; - meta: Rule.RuleMetaData; - options: unknown[] | undefined; -}; +import type { RuleData } from './parse'; +import { detectConfigVersion, selectRulesLoader } from './versions'; type RulesMap = Record>; export async function listRules(targets: ESLintTarget[]): Promise { - const rulesMap = await targets.reduce(async (acc, { eslintrc, patterns }) => { - const eslint = setupESLint(eslintrc); - const prev = await acc; - const curr = await loadRulesMap(eslint, patterns); - return mergeRulesMaps(prev, curr); + const version = await detectConfigVersion(); + const loadRulesMap = selectRulesLoader(version); + + const rulesMap = await targets.reduce(async (acc, target) => { + const map = await acc; + const rules = await loadRulesMap(target); + return rules.reduce(mergeRuleIntoMap, map); }, Promise.resolve({})); return Object.values(rulesMap).flatMap(Object.values); } -async function loadRulesMap( - eslint: ESLint, - patterns: string | string[], -): Promise { - const configs = await toArray(patterns).reduce( - async (acc, pattern) => [ - ...(await acc), - (await eslint.calculateConfigForFile(pattern)) as Linter.Config, - ], - Promise.resolve([]), - ); - - const rulesIds = distinct( - configs.flatMap(config => Object.keys(config.rules ?? {})), - ); - const rulesMeta = eslint.getRulesMetaForResults([ - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - { - messages: rulesIds.map(ruleId => ({ ruleId })), - suppressedMessages: [] as Linter.SuppressedLintMessage[], - } as ESLint.LintResult, - ]); - - return configs - .flatMap(config => Object.entries(config.rules ?? {})) - .filter(([, ruleEntry]) => ruleEntry != null && !isRuleOff(ruleEntry)) - .reduce((acc, [ruleId, ruleEntry]) => { - const meta = rulesMeta[ruleId]; - if (!meta) { - ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`); - return acc; - } - const options = toArray(ruleEntry).slice(1); - const optionsHash = jsonHash(options); - const ruleData: RuleData = { - ruleId, - meta, - options, - }; - return { - ...acc, - [ruleId]: { - ...acc[ruleId], - [optionsHash]: ruleData, - }, - }; - }, {}); -} - -function mergeRulesMaps(prev: RulesMap, curr: RulesMap): RulesMap { - return Object.entries(curr).reduce( - (acc, [ruleId, ruleVariants]) => ({ - ...acc, - [ruleId]: { - ...acc[ruleId], - ...ruleVariants, - }, - }), - prev, - ); -} - -function isRuleOff(entry: Linter.RuleEntry): boolean { - const level: Linter.RuleLevel = Array.isArray(entry) ? entry[0] : entry; - - switch (level) { - case 0: - case 'off': - return true; - case 1: - case 2: - case 'warn': - case 'error': - return false; - } -} - -export function parseRuleId(ruleId: string): { plugin?: string; name: string } { - const i = ruleId.lastIndexOf('/'); - if (i < 0) { - return { name: ruleId }; - } +function mergeRuleIntoMap(map: RulesMap, rule: RuleData): RulesMap { return { - plugin: ruleId.slice(0, i), - name: ruleId.slice(i + 1), + ...map, + [rule.ruleId]: { + ...map[rule.ruleId], + [jsonHash(rule.options)]: rule, + }, }; } diff --git a/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts b/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts index 14d9c447b..e70a6a1e8 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.unit.test.ts @@ -2,7 +2,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { MockInstance } from 'vitest'; import type { ESLintTarget } from '../config'; -import { type RuleData, listRules, parseRuleId } from './rules'; +import type { RuleData } from './parse'; +import { listRules } from './rules'; describe('listRules', () => { const fixturesDir = join( @@ -160,34 +161,3 @@ describe('listRules', () => { }); }); }); - -describe('parseRuleId', () => { - it.each([ - { - ruleId: 'prefer-const', - name: 'prefer-const', - }, - { - ruleId: 'sonarjs/no-identical-functions', - plugin: 'sonarjs', - name: 'no-identical-functions', - }, - { - ruleId: '@typescript-eslint/no-non-null-assertion', - plugin: '@typescript-eslint', - name: 'no-non-null-assertion', - }, - { - ruleId: 'no-secrets/no-secrets', - plugin: 'no-secrets', - name: 'no-secrets', - }, - { - ruleId: '@angular-eslint/template/no-negated-async', - plugin: '@angular-eslint/template', - name: 'no-negated-async', - }, - ])('$ruleId => name: $name, plugin: $plugin', ({ ruleId, name, plugin }) => { - expect(parseRuleId(ruleId)).toEqual({ name, plugin }); - }); -}); diff --git a/packages/plugin-eslint/src/lib/meta/transform.ts b/packages/plugin-eslint/src/lib/meta/transform.ts index 02759aaff..549aeed6d 100644 --- a/packages/plugin-eslint/src/lib/meta/transform.ts +++ b/packages/plugin-eslint/src/lib/meta/transform.ts @@ -1,7 +1,7 @@ import type { Audit } from '@code-pushup/models'; import { truncateDescription, truncateTitle } from '@code-pushup/utils'; import { ruleIdToSlug } from './hash'; -import type { RuleData } from './rules'; +import type { RuleData } from './parse'; export function ruleToAudit({ ruleId, meta, options }: RuleData): Audit { const name = ruleId.split('/').at(-1) ?? ruleId; diff --git a/packages/plugin-eslint/src/lib/meta/versions/detect.ts b/packages/plugin-eslint/src/lib/meta/versions/detect.ts new file mode 100644 index 000000000..09baca7c6 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/detect.ts @@ -0,0 +1,24 @@ +import { ESLint } from 'eslint'; +import { fileExists } from '@code-pushup/utils'; +import type { ConfigFormat } from './formats'; + +// relevant ESLint docs: +// - https://eslint.org/docs/latest/use/configure/configuration-files +// - https://eslint.org/docs/latest/use/configure/configuration-files-deprecated +// - https://eslint.org/docs/v8.x/use/configure/configuration-files-new + +export async function detectConfigVersion(): Promise { + if (process.env['ESLINT_USE_FLAT_CONFIG'] === 'true') { + return 'flat'; + } + if (process.env['ESLINT_USE_FLAT_CONFIG'] === 'false') { + return 'legacy'; + } + if (ESLint.version.startsWith('8.')) { + if (await fileExists('eslint.config.js')) { + return 'flat'; + } + return 'legacy'; + } + return 'flat'; +} diff --git a/packages/plugin-eslint/src/lib/meta/versions/detect.unit.test.ts b/packages/plugin-eslint/src/lib/meta/versions/detect.unit.test.ts new file mode 100644 index 000000000..2aa4ff50a --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/detect.unit.test.ts @@ -0,0 +1,36 @@ +import { ESLint } from 'eslint'; +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { detectConfigVersion } from './detect'; + +describe('detectConfigVersion', () => { + beforeEach(() => { + vol.fromJSON({}, MEMFS_VOLUME); + }); + + it('should assume flat config if explicitly enabled using environment variable', async () => { + vi.stubEnv('ESLINT_USE_FLAT_CONFIG', 'true'); + await expect(detectConfigVersion()).resolves.toBe('flat'); + }); + + it('should assume legacy config if explicitly disabled using environment variable', async () => { + vi.stubEnv('ESLINT_USE_FLAT_CONFIG', 'false'); + await expect(detectConfigVersion()).resolves.toBe('legacy'); + }); + + it('should assume legacy config for version 8', async () => { + vi.spyOn(ESLint, 'version', 'get').mockReturnValue('8.56.0'); + await expect(detectConfigVersion()).resolves.toBe('legacy'); + }); + + it('should assume flat config for version 8 when eslint.config.js file exists', async () => { + vi.spyOn(ESLint, 'version', 'get').mockReturnValue('8.56.0'); + vol.fromJSON({ 'eslint.config.js': '' }, MEMFS_VOLUME); + await expect(detectConfigVersion()).resolves.toBe('flat'); + }); + + it('should assume flat config for version 9', async () => { + vi.spyOn(ESLint, 'version', 'get').mockReturnValue('9.14.0'); + await expect(detectConfigVersion()).resolves.toBe('flat'); + }); +}); diff --git a/packages/plugin-eslint/src/lib/meta/versions/flat.integration.test.ts b/packages/plugin-eslint/src/lib/meta/versions/flat.integration.test.ts new file mode 100644 index 000000000..8ba0dba7b --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/flat.integration.test.ts @@ -0,0 +1,205 @@ +import type { ESLint, Linter, Rule } from 'eslint'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { RuleData } from '../parse'; +import { loadRulesForFlatConfig } from './flat'; + +describe('loadRulesForFlatConfig', () => { + const workDir = join( + process.cwd(), + 'tmp', + 'plugin-eslint', + 'flat-config-tests', + ); + + beforeEach(async () => { + vi.spyOn(process, 'cwd').mockReturnValue(workDir); + await mkdir(workDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(workDir, { force: true, recursive: true }); + }); + + it('should load built-in rules from implicit flat config location', async () => { + const config: Linter.FlatConfig = { + rules: { + 'no-unused-vars': 'error', + 'prefer-const': 'warn', + }, + }; + await writeFile( + join(workDir, 'eslint.config.js'), + `export default ${JSON.stringify(config, null, 2)}`, + ); + + const expectedMeta = expect.objectContaining({ + docs: expect.objectContaining({ + description: expect.stringMatching(/\w+/), + url: expect.stringContaining('https://eslint.org/'), + }), + }); + await expect(loadRulesForFlatConfig({})).resolves.toEqual([ + { ruleId: 'no-unused-vars', meta: expectedMeta, options: [] }, + { ruleId: 'prefer-const', meta: expectedMeta, options: [] }, + ] satisfies RuleData[]); + }); + + it('should load plugin rules from explicit flat config path', async () => { + const tseslint = { + rules: { + 'no-explicit-any': { + meta: { + docs: { + description: 'Disallow the `any` type', + url: 'https://typescript-eslint.io/rules/no-explicit-any/', + }, + }, + } as Rule.RuleModule, + 'no-unsafe-call': { + meta: { + docs: { + description: 'Disallow calling a value with type `any`', + url: 'https://typescript-eslint.io/rules/no-unsafe-call/', + }, + }, + } as Rule.RuleModule, + }, + } as ESLint.Plugin; + const reactHooks = { + rules: { + 'rules-of-hooks': { + meta: { + docs: { + description: 'enforces the Rules of Hooks', + url: 'https://reactjs.org/docs/hooks-rules.html', + }, + }, + } as Rule.RuleModule, + }, + } as ESLint.Plugin; + const config: Linter.FlatConfig[] = [ + { + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + { + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + }, + }, + { + rules: { + '@typescript-eslint/no-unsafe-call': 'error', + }, + }, + ]; + await writeFile( + join(workDir, 'code-pushup.eslint.config.js'), + `export default ${JSON.stringify(config, null, 2)}`, + ); + + await expect( + loadRulesForFlatConfig({ eslintrc: 'code-pushup.eslint.config.js' }), + ).resolves.toEqual([ + { + ruleId: '@typescript-eslint/no-explicit-any', + meta: { + docs: { + description: 'Disallow the `any` type', + url: 'https://typescript-eslint.io/rules/no-explicit-any/', + }, + }, + options: [], + }, + { + ruleId: 'react-hooks/rules-of-hooks', + meta: { + docs: { + description: 'enforces the Rules of Hooks', + url: 'https://reactjs.org/docs/hooks-rules.html', + }, + }, + options: [], + }, + { + ruleId: '@typescript-eslint/no-unsafe-call', + meta: { + docs: { + description: 'Disallow calling a value with type `any`', + url: 'https://typescript-eslint.io/rules/no-unsafe-call/', + }, + }, + options: [], + }, + ] satisfies RuleData[]); + }); + + it('should load custom rule options', async () => { + const config: Linter.FlatConfig[] = [ + { + rules: { + complexity: ['warn', 30], + eqeqeq: ['error', 'always', { null: 'never' }], + }, + }, + ]; + await writeFile( + join(workDir, 'eslint.config.cjs'), + `module.exports = ${JSON.stringify(config, null, 2)}`, + ); + + await expect(loadRulesForFlatConfig({})).resolves.toEqual([ + { + ruleId: 'complexity', + meta: expect.any(Object), + options: [30], + }, + { + ruleId: 'eqeqeq', + meta: expect.any(Object), + options: ['always', { null: 'never' }], + }, + ] satisfies RuleData[]); + }); + + it('should create multiple rule instances when different options used', async () => { + const config: Linter.FlatConfig[] = [ + { + rules: { + 'max-lines': ['warn', { max: 300 }], + }, + }, + { + files: ['**/*.test.js'], + rules: { + 'max-lines': ['warn', { max: 500 }], + }, + }, + ]; + await writeFile( + join(workDir, 'eslint.config.mjs'), + `export default ${JSON.stringify(config, null, 2)}`, + ); + + await expect(loadRulesForFlatConfig({})).resolves.toEqual([ + { + ruleId: 'max-lines', + meta: expect.any(Object), + options: [{ max: 300 }], + }, + { + ruleId: 'max-lines', + meta: expect.any(Object), + options: [{ max: 500 }], + }, + ] satisfies RuleData[]); + }); +}); diff --git a/packages/plugin-eslint/src/lib/meta/versions/flat.ts b/packages/plugin-eslint/src/lib/meta/versions/flat.ts new file mode 100644 index 000000000..2fca56c38 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/flat.ts @@ -0,0 +1,116 @@ +import type { Linter, Rule } from 'eslint'; +// eslint-disable-next-line import/no-deprecated +import { builtinRules } from 'eslint/use-at-your-own-risk'; +import { isAbsolute, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { exists, findNearestFile, toArray, ui } from '@code-pushup/utils'; +import type { ESLintTarget } from '../../config'; +import { jsonHash } from '../hash'; +import { + type RuleData, + isRuleOff, + optionsFromRuleEntry, + parseRuleId, +} from '../parse'; + +export async function loadRulesForFlatConfig({ + eslintrc, +}: Pick): Promise { + const config = eslintrc + ? await loadConfigByPath(eslintrc) + : await loadConfigByDefaultLocation(); + const configs = toArray(config); + + const rules = findEnabledRulesWithOptions(configs); + return rules + .map(rule => { + const meta = findRuleMeta(rule.ruleId, configs); + if (!meta) { + ui().logger.warning(`Cannot find metadata for rule ${rule.ruleId}`); + return null; + } + return { ...rule, meta }; + }) + .filter(exists); +} + +type FlatConfig = Linter.FlatConfig | Linter.FlatConfig[]; + +async function loadConfigByDefaultLocation(): Promise { + const flatConfigFileNames = [ + 'eslint.config.js', + 'eslint.config.mjs', + 'eslint.config.cjs', + ]; + const configPath = await findNearestFile(flatConfigFileNames); + if (configPath) { + return loadConfigByPath(configPath); + } + throw new Error( + [ + `ESLint config file not found - expected ${flatConfigFileNames.join('/')} in ${process.cwd()} or some parent directory`, + 'If your ESLint config is in a non-standard location, use the `eslintrc` parameter to specify the path.', + ].join('\n'), + ); +} + +async function loadConfigByPath(path: string): Promise { + const absolutePath = isAbsolute(path) ? path : join(process.cwd(), path); + const url = pathToFileURL(absolutePath).toString(); + const mod = (await import(url)) as FlatConfig | { default: FlatConfig }; + return 'default' in mod ? mod.default : mod; +} + +function findEnabledRulesWithOptions( + configs: Linter.FlatConfig[], +): Omit[] { + const enabledRules = configs + .flatMap(({ rules }) => Object.entries(rules ?? {})) + .filter(([, entry]) => entry != null && !isRuleOff(entry)) + .map(([ruleId, entry]) => ({ + ruleId, + options: entry ? optionsFromRuleEntry(entry) : [], + })); + const uniqueRulesMap = new Map( + enabledRules.map(({ ruleId, options }) => [ + `${ruleId}::${jsonHash(options)}`, + { ruleId, options }, + ]), + ); + return [...uniqueRulesMap.values()]; +} + +function findRuleMeta( + ruleId: string, + configs: Linter.FlatConfig[], +): Rule.RuleMetaData | undefined { + const { plugin, name } = parseRuleId(ruleId); + if (!plugin) { + return findBuiltinRuleMeta(name); + } + return findPluginRuleMeta(plugin, name, configs); +} + +function findBuiltinRuleMeta(name: string): Rule.RuleMetaData | undefined { + // eslint-disable-next-line import/no-deprecated, deprecation/deprecation + const rule = builtinRules.get(name); + return rule?.meta; +} + +function findPluginRuleMeta( + plugin: string, + name: string, + configs: Linter.FlatConfig[], +): Rule.RuleMetaData | undefined { + const config = configs.find(({ plugins = {} }) => plugin in plugins); + const rule = config?.plugins?.[plugin]?.rules?.[name]; + + if (typeof rule === 'function') { + ui().logger.warning( + `Cannot parse metadata for rule ${plugin}/${name}, plugin registers it as a function`, + ); + return undefined; + } + + return rule?.meta; +} diff --git a/packages/plugin-eslint/src/lib/meta/versions/formats.ts b/packages/plugin-eslint/src/lib/meta/versions/formats.ts new file mode 100644 index 000000000..10cd35df4 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/formats.ts @@ -0,0 +1 @@ +export type ConfigFormat = 'flat' | 'legacy'; diff --git a/packages/plugin-eslint/src/lib/meta/versions/index.ts b/packages/plugin-eslint/src/lib/meta/versions/index.ts new file mode 100644 index 000000000..e7944ae25 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/index.ts @@ -0,0 +1,19 @@ +import type { ESLintTarget } from '../../config'; +import type { RuleData } from '../parse'; +import { loadRulesForFlatConfig } from './flat'; +import type { ConfigFormat } from './formats'; +import { loadRulesForLegacyConfig } from './legacy'; + +export { detectConfigVersion } from './detect'; +export type { ConfigFormat } from './formats'; + +export function selectRulesLoader( + version: ConfigFormat, +): (target: ESLintTarget) => Promise { + switch (version) { + case 'flat': + return loadRulesForFlatConfig; + case 'legacy': + return loadRulesForLegacyConfig; + } +} diff --git a/packages/plugin-eslint/src/lib/meta/versions/legacy.ts b/packages/plugin-eslint/src/lib/meta/versions/legacy.ts new file mode 100644 index 000000000..903dda9c7 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/versions/legacy.ts @@ -0,0 +1,51 @@ +import type { ESLint, Linter } from 'eslint'; +import { distinct, exists, toArray, ui } from '@code-pushup/utils'; +import type { ESLintTarget } from '../../config'; +import { setupESLint } from '../../setup'; +import { type RuleData, isRuleOff, optionsFromRuleEntry } from '../parse'; + +export async function loadRulesForLegacyConfig({ + eslintrc, + patterns, +}: ESLintTarget): Promise { + const eslint = await setupESLint(eslintrc); + + const configs = await toArray(patterns).reduce( + async (acc, pattern) => [ + ...(await acc), + (await eslint.calculateConfigForFile(pattern)) as Linter.Config, + ], + Promise.resolve([]), + ); + + const rulesIds = distinct( + configs.flatMap(config => Object.keys(config.rules ?? {})), + ); + const rulesMeta = eslint.getRulesMetaForResults([ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + { + messages: rulesIds.map(ruleId => ({ ruleId })), + suppressedMessages: [] as Linter.SuppressedLintMessage[], + } as ESLint.LintResult, + ]); + + return configs + .flatMap(config => Object.entries(config.rules ?? {})) + .map(([ruleId, ruleEntry]): RuleData | null => { + if (ruleEntry == null || isRuleOff(ruleEntry)) { + return null; + } + const meta = rulesMeta[ruleId]; + if (!meta) { + ui().logger.warning(`Metadata not found for ESLint rule ${ruleId}`); + return null; + } + const options = optionsFromRuleEntry(ruleEntry); + return { + ruleId, + meta, + options, + }; + }) + .filter(exists); +} diff --git a/packages/plugin-eslint/src/lib/runner.integration.test.ts b/packages/plugin-eslint/src/lib/runner.integration.test.ts index e8e7b379f..d354b27f7 100644 --- a/packages/plugin-eslint/src/lib/runner.integration.test.ts +++ b/packages/plugin-eslint/src/lib/runner.integration.test.ts @@ -52,8 +52,8 @@ describe('executeRunner', () => { expect(osAgnosticAuditOutputs(json)).toMatchSnapshot(); }); - it('should execute runner with inline config using @code-pushup/eslint-config', async () => { - await createPluginConfig({ extends: '@code-pushup' }); + it('should execute runner with custom config using @code-pushup/eslint-config', async () => { + await createPluginConfig('code-pushup.eslintrc.yml'); await executeRunner(); const json = await readJsonFile(RUNNER_OUTPUT_PATH); @@ -76,5 +76,5 @@ describe('executeRunner', () => { }, }), ); - }, 7000); + }, 15_000); }); diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index 1b1045883..f1a8595d3 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -1,7 +1,5 @@ import type { ESLint, Linter } from 'eslint'; -import { rm, writeFile } from 'node:fs/promises'; import { platform } from 'node:os'; -import { join } from 'node:path'; import { distinct, executeProcess, @@ -17,43 +15,40 @@ export async function lint({ patterns, }: ESLintTarget): Promise { const results = await executeLint({ eslintrc, patterns }); - const ruleOptionsPerFile = await loadRuleOptionsPerFile(eslintrc, results); + const eslint = await setupESLint(eslintrc); + const ruleOptionsPerFile = await loadRuleOptionsPerFile(eslint, results); return { results, ruleOptionsPerFile }; } -function executeLint({ +async function executeLint({ eslintrc, patterns, }: ESLintTarget): Promise { - return withConfig(eslintrc, async configPath => { - // running as CLI because ESLint#lintFiles() runs out of memory - const { stdout } = await executeProcess({ - command: 'npx', - args: [ - 'eslint', - ...(configPath ? [`--config=${filePathToCliArg(configPath)}`] : []), - ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), - '--no-error-on-unmatched-pattern', - '--format=json', - ...toArray(patterns).map(pattern => - // globs need to be escaped on Unix - platform() === 'win32' ? pattern : `'${pattern}'`, - ), - ], - ignoreExitCode: true, - cwd: process.cwd(), - }); - - return JSON.parse(stdout) as ESLint.LintResult[]; + // running as CLI because ESLint#lintFiles() runs out of memory + const { stdout } = await executeProcess({ + command: 'npx', + args: [ + 'eslint', + ...(eslintrc ? [`--config=${filePathToCliArg(eslintrc)}`] : []), + ...(typeof eslintrc === 'object' ? ['--no-eslintrc'] : []), + '--no-error-on-unmatched-pattern', + '--format=json', + ...toArray(patterns).map(pattern => + // globs need to be escaped on Unix + platform() === 'win32' ? pattern : `'${pattern}'`, + ), + ], + ignoreExitCode: true, + cwd: process.cwd(), }); + + return JSON.parse(stdout) as ESLint.LintResult[]; } function loadRuleOptionsPerFile( - eslintrc: ESLintTarget['eslintrc'], + eslint: ESLint, results: ESLint.LintResult[], ): Promise { - const eslint = setupESLint(eslintrc); - return results.reduce(async (acc, { filePath, messages }) => { const filesMap = await acc; const config = (await eslint.calculateConfigForFile( @@ -79,28 +74,3 @@ function loadRuleOptionsPerFile( }; }, Promise.resolve({})); } - -async function withConfig( - eslintrc: ESLintTarget['eslintrc'], - fn: (configPath: string | undefined) => Promise, -): Promise { - if (typeof eslintrc !== 'object') { - return fn(eslintrc); - } - - const configPath = generateTempConfigPath(); - await writeFile(configPath, JSON.stringify(eslintrc)); - - try { - return await fn(configPath); - } finally { - await rm(configPath); - } -} - -function generateTempConfigPath(): string { - return join( - process.cwd(), - `.eslintrc.${Math.random().toString().slice(2)}.json`, - ); -} diff --git a/packages/plugin-eslint/src/lib/setup.ts b/packages/plugin-eslint/src/lib/setup.ts index bad15a4d9..50a7e3364 100644 --- a/packages/plugin-eslint/src/lib/setup.ts +++ b/packages/plugin-eslint/src/lib/setup.ts @@ -1,13 +1,19 @@ import { ESLint } from 'eslint'; import type { ESLintTarget } from './config'; -export function setupESLint(eslintrc: ESLintTarget['eslintrc']) { - return new ESLint({ - ...(typeof eslintrc === 'string' && { overrideConfigFile: eslintrc }), - ...(typeof eslintrc === 'object' && { - baseConfig: eslintrc, - useEslintrc: false, - }), +export async function setupESLint(eslintrc: ESLintTarget['eslintrc']) { + const eslintConstructor = await loadESLint(); + return new eslintConstructor({ + overrideConfigFile: eslintrc, errorOnUnmatchedPattern: false, }); } + +async function loadESLint() { + const eslint = await import('eslint'); + // loadESLint added to public API in v9, selects ESLint or LegacyESLint based on environment + if ('loadESLint' in eslint && typeof eslint.loadESLint === 'function') { + return (await eslint.loadESLint()) as typeof ESLint; + } + return ESLint; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 60034ebcb..b864605d1 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -15,6 +15,7 @@ export { fileExists, filePathToCliArg, findLineNumberInText, + findNearestFile, importModule, logMultipleFileResults, pluginWorkDir, diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 286adbcb9..e849e3caa 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -1,7 +1,7 @@ import { bold, gray } from 'ansis'; import { type Options, bundleRequire } from 'bundle-require'; import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { formatBytes } from './formatting'; import { logMultipleResults } from './log-results'; import { ui } from './logging'; @@ -120,6 +120,27 @@ export async function crawlFileSystem( return resultsNestedArray.flat() as T[]; } +export async function findNearestFile( + fileNames: string[], + cwd = process.cwd(), +): Promise { + // eslint-disable-next-line functional/no-loop-statements + for ( + // eslint-disable-next-line functional/no-let + let directory = cwd; + directory !== dirname(directory); + directory = dirname(directory) + ) { + // eslint-disable-next-line functional/no-loop-statements + for (const file of fileNames) { + if (await fileExists(join(directory, file))) { + return join(directory, file); + } + } + } + return undefined; +} + export function findLineNumberInText( content: string, pattern: string, diff --git a/packages/utils/src/lib/file-system.unit.test.ts b/packages/utils/src/lib/file-system.unit.test.ts index 0a6d6f05f..1888fc5e6 100644 --- a/packages/utils/src/lib/file-system.unit.test.ts +++ b/packages/utils/src/lib/file-system.unit.test.ts @@ -9,6 +9,7 @@ import { ensureDirectoryExists, filePathToCliArg, findLineNumberInText, + findNearestFile, logMultipleFileResults, projectToFilename, } from './file-system'; @@ -57,9 +58,9 @@ describe('crawlFileSystem', () => { beforeEach(() => { vol.fromJSON( { - ['README.md']: '# Markdown', - ['src/README.md']: '# Markdown', - ['src/index.ts']: 'const var = "markdown";', + 'README.md': '# Markdown', + 'src/README.md': '# Markdown', + 'src/index.ts': 'const var = "markdown";', }, MEMFS_VOLUME, ); @@ -110,6 +111,108 @@ describe('crawlFileSystem', () => { }); }); +describe('findNearestFile', () => { + it('should find file in current working directory', async () => { + vol.fromJSON( + { + 'eslint.config.js': '', + }, + MEMFS_VOLUME, + ); + await expect(findNearestFile(['eslint.config.js'])).resolves.toBe( + join(MEMFS_VOLUME, 'eslint.config.js'), + ); + }); + + it('should find first matching file in array', async () => { + vol.fromJSON( + { + 'eslint.config.cjs': '', + 'eslint.config.mjs': '', + }, + MEMFS_VOLUME, + ); + await expect( + findNearestFile([ + 'eslint.config.js', + 'eslint.config.cjs', + 'eslint.config.mjs', + ]), + ).resolves.toBe(join(MEMFS_VOLUME, 'eslint.config.cjs')); + }); + + it('should resolve to undefined if file not found', async () => { + vol.fromJSON({ '.eslintrc.json': '' }, MEMFS_VOLUME); + await expect( + findNearestFile([ + 'eslint.config.js', + 'eslint.config.cjs', + 'eslint.config.mjs', + ]), + ).resolves.toBeUndefined(); + }); + + it('should find file in parent directory', async () => { + vol.fromJSON( + { + 'eslint.config.js': '', + 'e2e/main.spec.js': '', + }, + MEMFS_VOLUME, + ); + await expect( + findNearestFile( + ['eslint.config.js', 'eslint.config.cjs', 'eslint.config.mjs'], + join(MEMFS_VOLUME, 'e2e'), + ), + ).resolves.toBe(join(MEMFS_VOLUME, 'eslint.config.js')); + }); + + it('should find file in directory multiple levels up', async () => { + vol.fromJSON( + { + 'eslint.config.cjs': '', + 'packages/core/package.json': '', + }, + MEMFS_VOLUME, + ); + await expect( + findNearestFile( + ['eslint.config.js', 'eslint.config.cjs', 'eslint.config.mjs'], + join(MEMFS_VOLUME, 'packages/core'), + ), + ).resolves.toBe(join(MEMFS_VOLUME, 'eslint.config.cjs')); + }); + + it("should find file that's nearest to current folder", async () => { + vol.fromJSON( + { + 'eslint.config.js': '', + 'packages/core/eslint.config.js': '', + 'packages/core/package.json': '', + }, + MEMFS_VOLUME, + ); + await expect( + findNearestFile( + ['eslint.config.js', 'eslint.config.cjs', 'eslint.config.mjs'], + join(MEMFS_VOLUME, 'packages/core'), + ), + ).resolves.toBe(join(MEMFS_VOLUME, 'packages/core/eslint.config.js')); + }); + + it('should not find file in sub-folders of current folder', async () => { + vol.fromJSON({ 'packages/core/eslint.config.js': '' }, MEMFS_VOLUME); + await expect( + findNearestFile([ + 'eslint.config.js', + 'eslint.config.cjs', + 'eslint.config.mjs', + ]), + ).resolves.toBeUndefined(); + }); +}); + describe('findLineNumberInText', () => { it('should return correct line number', () => { expect(