diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcd7163f..6aff8cda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Added - add [`enforce-node-protocol-usage`] rule and `import/node-version` setting ([#3024], thanks [@GoldStrikeArch] and [@sevenc-nanashi]) - add TypeScript types ([#3097], thanks [@G-Rath]) +- [`extensions`]: add `pathGroupOverrides to allow enforcement decision overrides based on specifier ([#3105], thanks [@Xunnamius]) ### Fixed - [`no-unused-modules`]: provide more meaningful error message when no .eslintrc is present ([#3116], thanks [@michaelfaith]) @@ -1170,6 +1171,7 @@ for info on changes for earlier releases. [#3122]: https://github.com/import-js/eslint-plugin-import/pull/3122 [#3116]: https://github.com/import-js/eslint-plugin-import/pull/3116 [#3106]: https://github.com/import-js/eslint-plugin-import/pull/3106 +[#3105]: https://github.com/import-js/eslint-plugin-import/pull/3105 [#3097]: https://github.com/import-js/eslint-plugin-import/pull/3097 [#3073]: https://github.com/import-js/eslint-plugin-import/pull/3073 [#3072]: https://github.com/import-js/eslint-plugin-import/pull/3072 diff --git a/src/rules/extensions.js b/src/rules/extensions.js index c2c03a2b1..2aeef6475 100644 --- a/src/rules/extensions.js +++ b/src/rules/extensions.js @@ -1,5 +1,6 @@ import path from 'path'; +import minimatch from 'minimatch'; import resolve from 'eslint-module-utils/resolve'; import { isBuiltIn, isExternalModule, isScoped } from '../core/importType'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; @@ -16,6 +17,26 @@ const properties = { pattern: patternProperties, checkTypeImports: { type: 'boolean' }, ignorePackages: { type: 'boolean' }, + pathGroupOverrides: { + type: 'array', + items: { + type: 'object', + properties: { + pattern: { + type: 'string', + }, + patternOptions: { + type: 'object', + }, + action: { + type: 'string', + enum: ['enforce', 'ignore'], + }, + }, + additionalProperties: false, + required: ['pattern', 'action'], + }, + }, }, }; @@ -54,6 +75,10 @@ function buildProperties(context) { if (obj.checkTypeImports !== undefined) { result.checkTypeImports = obj.checkTypeImports; } + + if (obj.pathGroupOverrides !== undefined) { + result.pathGroupOverrides = obj.pathGroupOverrides; + } }); if (result.defaultConfig === 'ignorePackages') { @@ -143,20 +168,39 @@ module.exports = { return false; } + function computeOverrideAction(pathGroupOverrides, path) { + for (let i = 0, l = pathGroupOverrides.length; i < l; i++) { + const { pattern, patternOptions, action } = pathGroupOverrides[i]; + if (minimatch(path, pattern, patternOptions || { nocomment: true })) { + return action; + } + } + } + function checkFileExtension(source, node) { // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor if (!source || !source.value) { return; } const importPathWithQueryString = source.value; + // If not undefined, the user decided if rules are enforced on this import + const overrideAction = computeOverrideAction( + props.pathGroupOverrides || [], + importPathWithQueryString, + ); + + if (overrideAction === 'ignore') { + return; + } + // don't enforce anything on builtins - if (isBuiltIn(importPathWithQueryString, context.settings)) { return; } + if (!overrideAction && isBuiltIn(importPathWithQueryString, context.settings)) { return; } const importPath = importPathWithQueryString.replace(/\?(.*)$/, ''); // don't enforce in root external packages as they may have names with `.js`. // Like `import Decimal from decimal.js`) - if (isExternalRootModule(importPath)) { return; } + if (!overrideAction && isExternalRootModule(importPath)) { return; } const resolvedPath = resolve(importPath, context); @@ -174,7 +218,7 @@ module.exports = { if (!extension || !importPath.endsWith(`.${extension}`)) { // ignore type-only imports and exports if (!props.checkTypeImports && (node.importKind === 'type' || node.exportKind === 'type')) { return; } - const extensionRequired = isUseOfExtensionRequired(extension, isPackage); + const extensionRequired = isUseOfExtensionRequired(extension, !overrideAction && isPackage); const extensionForbidden = isUseOfExtensionForbidden(extension); if (extensionRequired && !extensionForbidden) { context.report({ diff --git a/tests/src/rules/extensions.js b/tests/src/rules/extensions.js index 883dfab65..8843713e3 100644 --- a/tests/src/rules/extensions.js +++ b/tests/src/rules/extensions.js @@ -736,6 +736,86 @@ describe('TypeScript', () => { ], parser, }), + + // pathGroupOverrides: no patterns match good bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src.ts'; + import { $exists } from 'rootverse+bfe:src/symbols.ts'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'multiverse{*,*/**}', + action: 'enforce', + }, + ], + }, + ], + }), + // pathGroupOverrides: an enforce pattern matches good bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src.ts'; + import { $exists } from 'rootverse+bfe:src/symbols.ts'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'rootverse{*,*/**}', + action: 'enforce', + }, + ], + }, + ], + }), + // pathGroupOverrides: an ignore pattern matches bad bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src'; + import { $exists } from 'rootverse+bfe:src/symbols'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'multiverse{*,*/**}', + action: 'enforce', + }, + { + pattern: 'rootverse{*,*/**}', + action: 'ignore', + }, + ], + }, + ], + }), ], invalid: [ test({ @@ -756,6 +836,46 @@ describe('TypeScript', () => { ], parser, }), + + // pathGroupOverrides: an enforce pattern matches bad bespoke specifiers + test({ + code: ` + import { ErrorMessage as UpstreamErrorMessage } from '@black-flag/core/util'; + + import { $instances } from 'rootverse+debug:src'; + import { $exists } from 'rootverse+bfe:src/symbols'; + + import type { Entries } from 'type-fest'; + `, + parser, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'rootverse{*,*/**}', + action: 'enforce', + }, + { + pattern: 'universe{*,*/**}', + action: 'ignore', + }, + ], + }, + ], + errors: [ + { + message: 'Missing file extension for "rootverse+debug:src"', + line: 4, + }, + { + message: 'Missing file extension for "rootverse+bfe:src/symbols"', + line: 5, + }, + ], + }), ], }); });