diff --git a/README.md b/README.md index b02fd9e5..486a3d95 100644 --- a/README.md +++ b/README.md @@ -75,23 +75,23 @@ module.exports = { #### Configuration options -| Name | Description                               | Type | Required | Default | Example | -| :-------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :----------------- | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------- | -| `repositories` | Repositories to scan in format of `owner/project`. See [eslint-remote-tester-repositories] for shared list of repositories. | `string[]` | :white_check_mark: | :x: | `['mui-org/material-ui', 'reach/reach-ui']` | -| `extensions` | Extensions of files under scanning | `string[]` | :white_check_mark: | :x: | `['js', 'jsx', 'ts', 'tsx']` | -| `eslintrc` | ESLint configuration | See [Configuring ESLint] | :white_check_mark: | :x: | `{ root: true, extends: ['eslint:all'] }` | -| `pathIgnorePattern` | Regexp pattern string used to exclude paths | `string` | :x: | :x: | `(node_modules\|docs\|\\/\\.git)` | -| `maxFileSizeBytes` | Max file size used to exclude bigger files | `number` | :x: | `2000000` | `1500000` | -| `rulesUnderTesting` | Array of rules or a filter method used to filter out results. Use `undefined` or empty array when ESLint crashes are the only interest. Filter method is called with `ruleId` and `options`. | `string[] \| (ruleId, { repository }) => boolean` | :x: | `[]` | `['no-empty', 'react/sort-prop-types']` `(ruleId, options) => ruleId === 'no-undef' && options.repository === 'owner/repo'` | -| `resultParser` | Syntax for the result parser | `plaintext\|markdown` | :x: | `markdown` on CLI. `plaintext` on CI | `markdown` | -| `concurrentTasks` | Maximum amount of tasks run concurrently | `number` | :x: | `5` | `3` | -| `CI` | Flag used to set CI mode. `process.env.CI` is used when not set. | `boolean` | :x: | value of `process.env.CI === 'true'` | `true` | -| `logLevel` | Filter log messages based on their priority | `verbose\|info\|warn\|error` | :x: | `verbose` | `warn` | -| `cache` | Flag used to enable caching of cloned repositories. For CIs it's ideal to disable caching due to limited disk space. | `boolean` | :x: | `true` | `true` | -| `timeLimit` | Time limit before scan is interrupted and **exited successfully**. Ideal for avoiding CI timeouts in regression tests. | `number` | :x: | `5.5 * 60 * 60, // 5 hours 30 minutes` | `5 * 60 * 60 // 5 hours` | -| `compare` | Flag used to enable result comparison mode. Compares results of two scans and output the diff. Ideal for identifying new false positives when fixing existing rules. See [Fixing existing rules]. | `boolean` | :x: | `false` | `true` | -| `updateComparisonReference` | Flag used to enable result comparison reference updating. Indicates whether comparison base should be updated after scan has finished. Ideal to be turned off once initial comparison base has been collected. | `boolean` | :x: | `true` | `true` | -| `onComplete` | Callback invoked once scan is completed. Asynchronous functions are supported. Ideal for extending the process with custom features. | `(results, comparisonResults, repositoryCount) => void`\|`Promise`. See [onComplete example]. | :x: | :x: | `async (results, comparisonResults, repositoryCount) => {}` | +| Name | Description                               | Type | Required | Default | Example | +| :-------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :----------------- | :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | +| `repositories` | Repositories to scan in format of `owner/project`. See [eslint-remote-tester-repositories] for shared list of repositories. | `string[]` | :white_check_mark: | :x: | `['mui-org/material-ui', 'reach/reach-ui']` | +| `extensions` | Extensions of files under scanning | `string[]` | :white_check_mark: | :x: | `['js', 'jsx', 'ts', 'tsx']` | +| `eslintrc` | ESLint configuration. Supports lazy initialization based on currently tested repository when a function is passed. Function is called with current repository and its location on filesystem. | `object \| ({ location, repository }) => object` See [Configuring ESLint] | :white_check_mark: | :x: | `{ root: true, extends: ['eslint:all'] }` `(options) => options.repository === 'my-repo' ? ({ extends: ['eslint:all'] }) : ({})` | +| `pathIgnorePattern` | Regexp pattern string used to exclude paths | `string` | :x: | :x: | `(node_modules\|docs\|\\/\\.git)` | +| `maxFileSizeBytes` | Max file size used to exclude bigger files | `number` | :x: | `2000000` | `1500000` | +| `rulesUnderTesting` | Array of rules or a filter method used to filter out results. Use `undefined` or empty array when ESLint crashes are the only interest. Filter method is called with `ruleId` and `options`. | `string[] \| (ruleId, { repository }) => boolean` | :x: | `[]` | `['no-empty', 'react/sort-prop-types']` `(ruleId, options) => ruleId === 'no-undef' && options.repository === 'owner/repo'` | +| `resultParser` | Syntax for the result parser | `plaintext\|markdown` | :x: | `markdown` on CLI. `plaintext` on CI | `markdown` | +| `concurrentTasks` | Maximum amount of tasks run concurrently | `number` | :x: | `5` | `3` | +| `CI` | Flag used to set CI mode. `process.env.CI` is used when not set. | `boolean` | :x: | value of `process.env.CI === 'true'` | `true` | +| `logLevel` | Filter log messages based on their priority | `verbose\|info\|warn\|error` | :x: | `verbose` | `warn` | +| `cache` | Flag used to enable caching of cloned repositories. For CIs it's ideal to disable caching due to limited disk space. | `boolean` | :x: | `true` | `true` | +| `timeLimit` | Time limit before scan is interrupted and **exited successfully**. Ideal for avoiding CI timeouts in regression tests. | `number` | :x: | `5.5 * 60 * 60, // 5 hours 30 minutes` | `5 * 60 * 60 // 5 hours` | +| `compare` | Flag used to enable result comparison mode. Compares results of two scans and output the diff. Ideal for identifying new false positives when fixing existing rules. See [Fixing existing rules]. | `boolean` | :x: | `false` | `true` | +| `updateComparisonReference` | Flag used to enable result comparison reference updating. Indicates whether comparison base should be updated after scan has finished. Ideal to be turned off once initial comparison base has been collected. | `boolean` | :x: | `true` | `true` | +| `onComplete` | Callback invoked once scan is completed. Asynchronous functions are supported. Ideal for extending the process with custom features. | `(results, comparisonResults, repositoryCount) => void`\|`Promise`. See [onComplete example]. | :x: | :x: | `async (results, comparisonResults, repositoryCount) => {}` | [configuring eslint]: https://eslint.org/docs/user-guide/configuring [fixing existing rules]: #fixing-existing-rules diff --git a/lib/config/types.ts b/lib/config/types.ts index a59858d9..4320a7e5 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -23,7 +23,12 @@ export interface Config { | ((ruleId: string, options: { repository: string }) => boolean); resultParser: ResultParser; concurrentTasks: number; - eslintrc: Linter.Config; + eslintrc: + | Linter.Config + | ((options?: { + repository: string; + location: string; + }) => Linter.Config); CI: boolean; logLevel: LogLevel; cache: boolean; diff --git a/lib/config/validator.ts b/lib/config/validator.ts index 26265b34..d2f64d76 100644 --- a/lib/config/validator.ts +++ b/lib/config/validator.ts @@ -126,19 +126,26 @@ export default async function validate( errors.push(validateStringArray('extensions', extensions)); - if (!eslintrc || Object.keys(eslintrc).length === 0) { + if (!eslintrc) { errors.push(`Missing eslintrc.`); } else { try { // This will throw when eslintrc is invalid const linter = new ESLint({ useEslintrc: false, - overrideConfig: eslintrc, + overrideConfig: + typeof eslintrc === 'function' ? eslintrc() : eslintrc, }); errors.push(await validateEslintRules(linter)); } catch (e) { errors.push(`eslintrc: ${e.message}`); + + if (typeof eslintrc === 'function') { + errors.push( + 'Note that "config.eslintrc" is called with empty options during configuration validation.' + ); + } } } diff --git a/lib/engine/worker-task.ts b/lib/engine/worker-task.ts index 3a59f62c..ffbd9fc2 100644 --- a/lib/engine/worker-task.ts +++ b/lib/engine/worker-task.ts @@ -1,11 +1,17 @@ import fs from 'fs'; import { parentPort, workerData } from 'worker_threads'; +import { resolve } from 'path'; import { ESLint, Linter } from 'eslint'; import { codeFrameColumns, SourceLocation } from '@babel/code-frame'; import { LintMessage, WorkerData } from './types'; import config from '@config'; -import { getFiles, removeCachedRepository, SourceFile } from '@file-client'; +import { + CACHE_LOCATION, + getFiles, + removeCachedRepository, + SourceFile, +} from '@file-client'; export type WorkerMessage = | { type: 'START' } @@ -183,19 +189,6 @@ const postMessage = (message: WorkerMessage) => { * - Keep progress-logger up-to-date of status via onMessage */ export default async function workerTask(): Promise { - const linter = new ESLint({ - useEslintrc: false, - overrideConfig: config.eslintrc, - - // Only rules set in configuration are expected. - // Ignore all inline configurations found from target repositories. - allowInlineConfig: false, - - // Lint all given files, ignore none. Cache is located under node_modules. - // config.pathIgnorePattern is used for exclusions. - ignore: false, - }); - const { repository } = workerData as WorkerData; const messageReducer = getMessageReducer(repository); @@ -209,6 +202,27 @@ export default async function workerTask(): Promise { onReadFailure: () => postMessage({ type: 'READ_FAILURE' }), }); + const eslintrc = + typeof config.eslintrc === 'function' + ? config.eslintrc({ + repository, + location: resolve(`${CACHE_LOCATION}/${repository}`), + }) + : config.eslintrc; + + const linter = new ESLint({ + useEslintrc: false, + overrideConfig: eslintrc, + + // Only rules set in configuration are expected. + // Ignore all inline configurations found from target repositories. + allowInlineConfig: false, + + // Lint all given files, ignore none. Cache is located under node_modules. + // config.pathIgnorePattern is used for exclusions. + ignore: false, + }); + postMessage({ type: 'LINT_START', payload: files.length }); for (const [index, file] of files.entries()) { diff --git a/test/integration/integration.test.ts b/test/integration/integration.test.ts index 41baadb6..50dd0396 100644 --- a/test/integration/integration.test.ts +++ b/test/integration/integration.test.ts @@ -9,6 +9,8 @@ import { REPOSITORY_CACHE, } from '../utils'; +const DEBUG_LOG_PATTERN = /\[DEBUG (\S|:)*\] /g; + describe('integration', () => { test('results are rendered on CI mode', async () => { const { output } = await runProductionBuild({ CI: true }); @@ -1065,31 +1067,31 @@ describe('integration', () => { }); const finalLog = output.pop(); - const withoutTimestamps = finalLog!.replace(/\[DEBUG (\S|:)*\]/g, ''); + const withoutTimestamps = finalLog!.replace(DEBUG_LOG_PATTERN, ''); expect(withoutTimestamps).toMatchInlineSnapshot(` "Full log: - no-undef - AriPerkkio/eslint-remote-tester-integration-test-target - no-var - AriPerkkio/eslint-remote-tester-integration-test-target - no-implicit-globals - AriPerkkio/eslint-remote-tester-integration-test-target - no-undef - AriPerkkio/eslint-remote-tester-integration-test-target - no-empty - AriPerkkio/eslint-remote-tester-integration-test-target - one-var - AriPerkkio/eslint-remote-tester-integration-test-target - vars-on-top - AriPerkkio/eslint-remote-tester-integration-test-target - no-var - AriPerkkio/eslint-remote-tester-integration-test-target - no-implicit-globals - AriPerkkio/eslint-remote-tester-integration-test-target - id-length - AriPerkkio/eslint-remote-tester-integration-test-target - quote-props - AriPerkkio/eslint-remote-tester-integration-test-target - getter-return - AriPerkkio/eslint-remote-tester-integration-test-target - space-before-function-paren - AriPerkkio/eslint-remote-tester-integration-test-target - strict - AriPerkkio/eslint-remote-tester-integration-test-target - space-before-blocks - AriPerkkio/eslint-remote-tester-integration-test-target - capitalized-comments - AriPerkkio/eslint-remote-tester-integration-test-target - no-compare-neg-zero - AriPerkkio/eslint-remote-tester-integration-test-target - no-magic-numbers - AriPerkkio/eslint-remote-tester-integration-test-target - indent - AriPerkkio/eslint-remote-tester-integration-test-target - capitalized-comments - AriPerkkio/eslint-remote-tester-integration-test-target - eol-last - AriPerkkio/eslint-remote-tester-integration-test-target + no-undef - AriPerkkio/eslint-remote-tester-integration-test-target + no-var - AriPerkkio/eslint-remote-tester-integration-test-target + no-implicit-globals - AriPerkkio/eslint-remote-tester-integration-test-target + no-undef - AriPerkkio/eslint-remote-tester-integration-test-target + no-empty - AriPerkkio/eslint-remote-tester-integration-test-target + one-var - AriPerkkio/eslint-remote-tester-integration-test-target + vars-on-top - AriPerkkio/eslint-remote-tester-integration-test-target + no-var - AriPerkkio/eslint-remote-tester-integration-test-target + no-implicit-globals - AriPerkkio/eslint-remote-tester-integration-test-target + id-length - AriPerkkio/eslint-remote-tester-integration-test-target + quote-props - AriPerkkio/eslint-remote-tester-integration-test-target + getter-return - AriPerkkio/eslint-remote-tester-integration-test-target + space-before-function-paren - AriPerkkio/eslint-remote-tester-integration-test-target + strict - AriPerkkio/eslint-remote-tester-integration-test-target + space-before-blocks - AriPerkkio/eslint-remote-tester-integration-test-target + capitalized-comments - AriPerkkio/eslint-remote-tester-integration-test-target + no-compare-neg-zero - AriPerkkio/eslint-remote-tester-integration-test-target + no-magic-numbers - AriPerkkio/eslint-remote-tester-integration-test-target + indent - AriPerkkio/eslint-remote-tester-integration-test-target + capitalized-comments - AriPerkkio/eslint-remote-tester-integration-test-target + eol-last - AriPerkkio/eslint-remote-tester-integration-test-target [ERROR] AriPerkkio/eslint-remote-tester-integration-test-target 21 errors [DONE] Finished scan of 1 repositories [INFO] Cached repositories (1) at ./node_modules/.cache-eslint-remote-tester @@ -1097,4 +1099,42 @@ describe('integration', () => { " `); }); + + test('calls eslintrc function with repository and its location', async () => { + const { output } = await runProductionBuild({ + CI: false, + logLevel: 'verbose', + eslintrc: options => { + const { parentPort } = require('worker_threads'); + + if (parentPort) { + parentPort.postMessage({ + type: 'DEBUG', + payload: `location: ${options?.location}`, + }); + + parentPort.postMessage({ + type: 'DEBUG', + payload: `repository: ${options?.repository}`, + }); + } + + return { root: true, extends: ['eslint:all'] }; + }, + }); + + const finalLog = output.pop(); + const withoutTimestamps = finalLog!.replace(DEBUG_LOG_PATTERN, ''); + + expect(withoutTimestamps).toMatchInlineSnapshot(` + "Full log: + location: /node_modules/.cache-eslint-remote-tester/AriPerkkio/eslint-remote-tester-integration-test-target + repository: AriPerkkio/eslint-remote-tester-integration-test-target + [ERROR] AriPerkkio/eslint-remote-tester-integration-test-target 5 errors + [DONE] Finished scan of 1 repositories + [INFO] Cached repositories (1) at ./node_modules/.cache-eslint-remote-tester + + " + `); + }); }); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 015ebe63..e565d316 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -157,12 +157,20 @@ describe('validateConfig', () => { }); }); + test('eslintrc accepts function', async () => { + await validateConfig({ + ...DEFAULT_CONFIGURATION, + eslintrc: () => ({ rules: {} }), + }); + }); + test('rulesUnderTesting is optional', async () => { await validateConfig({ ...DEFAULT_CONFIGURATION, rulesUnderTesting: undefined, }); }); + test('rulesUnderTesting accepts empty array', async () => { await validateConfig({ ...DEFAULT_CONFIGURATION, @@ -480,7 +488,7 @@ describe('validateConfig', () => { expect(validationError).toMatch('Missing eslintrc'); }); - test('eslintrc is validated', async () => { + test('eslintrc as object is validated', async () => { await validateConfig({ ...DEFAULT_CONFIGURATION, eslintrc: { 'not-valid-key': true } as any, @@ -495,6 +503,24 @@ describe('validateConfig', () => { ); }); + test('eslintrc as function is validated', async () => { + await validateConfig({ + ...DEFAULT_CONFIGURATION, + eslintrc: () => ({ 'not-valid-key': true } as any), + }); + + const [validationError] = getConsoleLogCalls(); + expect(validationError).toMatch( + 'eslintrc: ESLint configuration in CLIOptions is invalid' + ); + expect(validationError).toMatch( + 'Unexpected top-level property "not-valid-key"' + ); + expect(validationError).toMatch( + 'Note that "config.eslintrc" is called with empty options during configuration validation.' + ); + }); + test('rulesUnderTesting requires an array or function', async () => { const rulesUnderTesting: any = { length: 10 }; await validateConfig({ diff --git a/test/utils.ts b/test/utils.ts index 97a5975c..e66e8d43 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -24,6 +24,7 @@ const LAST_RENDER_PATTERN = /(Results|Full log)[\s|\S]*/; const COMPARISON_RESULTS_PATTERN = /(Comparison results:[\s|\S]*)Results/; const ON_COMPLETE_PATTERN = /("onComplete": )"([\s|\S]*)"/; const RULES_UNDER_TESTING_PATTERN = /("rulesUnderTesting": )"([\s|\S]*)",/; +const ESLINTRC_PATTERN = /("eslintrc": )"([\s|\S]*)",/; const ESCAPED_NEWLINE_PATTERN = /\\n/g; const DEBUG_LOG = '/tmp/test.debug.log'; @@ -50,10 +51,14 @@ function createConfiguration( if (typeof options.rulesUnderTesting === 'function') { config.rulesUnderTesting = options.rulesUnderTesting.toString() as any; } + if (typeof options.eslintrc === 'function') { + config.eslintrc = options.eslintrc.toString() as any; + } const configText = JSON.stringify(config, null, 4) .replace(ON_COMPLETE_PATTERN, '$1$2') .replace(RULES_UNDER_TESTING_PATTERN, '$1$2,') + .replace(ESLINTRC_PATTERN, '$1$2,') .replace(ESCAPED_NEWLINE_PATTERN, '\n'); fs.writeFileSync(name, `module.exports=${configText}`, 'utf8');