From c71d953742f16131842d000c958cf554a19f9419 Mon Sep 17 00:00:00 2001 From: Stephen Jason Wang Date: Sat, 17 May 2025 20:25:59 +0800 Subject: [PATCH 1/6] feat(extensions): support fix and pathGroupOverrides options --- src/rules/extensions.ts | 88 +++++++++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts index c1d2f160..ccda0555 100644 --- a/src/rules/extensions.ts +++ b/src/rules/extensions.ts @@ -9,6 +9,7 @@ import { moduleVisitor, resolve, } from '../utils/index.js' +import { minimatch } from 'minimatch' const modifierValues = ['always', 'ignorePackages', 'never'] as const @@ -32,6 +33,22 @@ const properties = { checkTypeImports: { type: 'boolean' as const, }, + pathGroupOverrides: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + pattern: { type: 'string' as const }, + patternOptions: { type: 'object' as const }, + action: { + type: 'string' as const, + enum: ['enforce', 'ignore'], + }, + }, + additionalProperties: false, + required: ['pattern', 'action'], + }, + }, }, } @@ -43,11 +60,21 @@ export interface OptionsItemWithPatternProperty { ignorePackages?: boolean checkTypeImports?: boolean pattern: ModifierByFileExtension + fix?: boolean + pathGroupOverrides?: PathGroupOverride[] +} + +export interface PathGroupOverride { + pattern: string + patternOptions?: Record + action: 'enforce' | 'ignore' } export interface OptionsItemWithoutPatternProperty { ignorePackages?: boolean checkTypeImports?: boolean + fix?: boolean + pathGroupOverrides?: PathGroupOverride[] } export type Options = @@ -63,6 +90,8 @@ export interface NormalizedOptions { pattern?: Record ignorePackages?: boolean checkTypeImports?: boolean + fix?: boolean + pathGroupOverrides?: PathGroupOverride[] } export type MessageId = 'missing' | 'missingKnown' | 'unexpected' @@ -73,6 +102,8 @@ function buildProperties(context: RuleContext) { pattern: {}, ignorePackages: false, checkTypeImports: false, + fix: false, + pathGroupOverrides: [], } for (const obj of context.options) { @@ -109,6 +140,14 @@ function buildProperties(context: RuleContext) { if (typeof obj.checkTypeImports === 'boolean') { result.checkTypeImports = obj.checkTypeImports } + + if ('fix' in obj) { + result.fix = Boolean(obj.fix) + } + + if ('pathGroupOverrides' in obj && Array.isArray(obj.pathGroupOverrides)) { + result.pathGroupOverrides = obj.pathGroupOverrides + } } if (result.defaultConfig === 'ignorePackages') { @@ -124,14 +163,15 @@ function isExternalRootModule(file: string) { return false } const slashCount = file.split('/').length - 1 + return slashCount === 0 || (isScoped(file) && slashCount <= 1) +} - if (slashCount === 0) { - return true - } - if (isScoped(file) && slashCount <= 1) { - return true +function computeOverrideAction(overrides: PathGroupOverride[], path: string) { + for (const { pattern, patternOptions, action } of overrides) { + if (minimatch(path, pattern, patternOptions || { nocomment: true })) { + return action + } } - return false } export default createRule({ @@ -143,6 +183,7 @@ export default createRule({ description: 'Ensure consistent use of file extension within the import path.', }, + fixable: 'code', schema: { anyOf: [ { @@ -220,9 +261,14 @@ export default createRule({ } const importPathWithQueryString = source.value + const overrideAction = computeOverrideAction( + props.pathGroupOverrides || [], + importPathWithQueryString, + ) + if (overrideAction === 'ignore') return // don't enforce anything on builtins - if (isBuiltIn(importPathWithQueryString, context.settings)) { + if (!overrideAction && isBuiltIn(importPathWithQueryString, context.settings)) { return } @@ -230,7 +276,7 @@ export default createRule({ // don't enforce in root external packages as they may have names with `.js`. // Like `import Decimal from decimal.js`) - if (isExternalRootModule(importPath)) { + if (!overrideAction && isExternalRootModule(importPath)) { return } @@ -261,7 +307,7 @@ export default createRule({ } const extensionRequired = isUseOfExtensionRequired( extension, - isPackage, + !overrideAction && isPackage, ) const extensionForbidden = isUseOfExtensionForbidden(extension) if (extensionRequired && !extensionForbidden) { @@ -272,6 +318,16 @@ export default createRule({ extension, importPath: importPathWithQueryString, }, + ...(props.fix && extension + ? { + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify(`${importPathWithQueryString}.${extension}`), + ) + }, + } + : {}), }) } } else if ( @@ -286,10 +342,18 @@ export default createRule({ extension, importPath: importPathWithQueryString, }, + ...(props.fix + ? { + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify(importPath.slice(0, -(extension.length + 1))), + ) + }, + } + : {}), }) } - }, - { commonjs: true }, - ) + }, { commonjs: true }) }, }) From 03c4f8102e04ce373e4834a82c4c94c6cf25a932 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 17 May 2025 13:06:57 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- src/rules/extensions.ts | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts index ccda0555..16428410 100644 --- a/src/rules/extensions.ts +++ b/src/rules/extensions.ts @@ -268,7 +268,10 @@ export default createRule({ if (overrideAction === 'ignore') return // don't enforce anything on builtins - if (!overrideAction && isBuiltIn(importPathWithQueryString, context.settings)) { + if ( + !overrideAction && + isBuiltIn(importPathWithQueryString, context.settings) + ) { return } @@ -320,13 +323,15 @@ export default createRule({ }, ...(props.fix && extension ? { - fix(fixer) { - return fixer.replaceText( - source, - JSON.stringify(`${importPathWithQueryString}.${extension}`), - ) - }, - } + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify( + `${importPathWithQueryString}.${extension}`, + ), + ) + }, + } : {}), }) } @@ -344,16 +349,20 @@ export default createRule({ }, ...(props.fix ? { - fix(fixer) { - return fixer.replaceText( - source, - JSON.stringify(importPath.slice(0, -(extension.length + 1))), - ) - }, - } + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify( + importPath.slice(0, -(extension.length + 1)), + ), + ) + }, + } : {}), }) } - }, { commonjs: true }) + }, + { commonjs: true }, + ) }, }) From 8cd5a895614b1fc7404ac6c1f486f0c5331c0246 Mon Sep 17 00:00:00 2001 From: JounQin Date: Sat, 17 May 2025 21:08:25 +0800 Subject: [PATCH 3/6] refactor: incorrect `minimatch` import order --- src/rules/extensions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts index 16428410..ff2975d2 100644 --- a/src/rules/extensions.ts +++ b/src/rules/extensions.ts @@ -1,5 +1,7 @@ import path from 'node:path' +import { minimatch } from 'minimatch' + import type { FileExtension, RuleContext } from '../types.js' import { isBuiltIn, @@ -9,7 +11,6 @@ import { moduleVisitor, resolve, } from '../utils/index.js' -import { minimatch } from 'minimatch' const modifierValues = ['always', 'ignorePackages', 'never'] as const From ffe866ceff43c7223fc0705411ab648c226f0ca4 Mon Sep 17 00:00:00 2001 From: JounQin Date: Sun, 18 May 2025 00:46:56 +0800 Subject: [PATCH 4/6] refactor: add suggestions support, add test cases Co-authored-by: "Xunnamius (Romulus)" --- .github/workflows/ci.yml | 4 +- README.md | 3 +- docs/rules/extensions.md | 2 + src/rules/extensions.ts | 147 ++++++++----- src/utils/index.ts | 1 + src/utils/parse-path.ts | 25 +++ test/rules/extensions.spec.ts | 193 ++++++++++++++++++ .../__snapshots__/parse-path.spec.ts.snap | 41 ++++ test/utils/parse-path.spec.ts | 19 ++ 9 files changed, 380 insertions(+), 55 deletions(-) create mode 100644 src/utils/parse-path.ts create mode 100644 test/utils/__snapshots__/parse-path.spec.ts.snap create mode 100644 test/utils/parse-path.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 234b9508..d3cbbb5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - 18 - 20 - 22 + - 24 eslint: - 8.56 - 8 @@ -28,7 +29,8 @@ jobs: include: - executeLint: true - node: 20 + node: 22 + eslint: 9 os: ubuntu-latest fail-fast: false diff --git a/README.md b/README.md index 33a47279..9121e94c 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ export default [ | [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | | | [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | | | [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | | -| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | | | | +| [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | 🔧 | 💡 | | | [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | | | [group-exports](docs/rules/group-exports.md) | Prefer named exports to be grouped together in a single export declaration. | | | | | | | | [imports-first](docs/rules/imports-first.md) | Replaced by `import-x/first`. | | | | 🔧 | | ❌ | @@ -700,7 +700,6 @@ Detailed changes for each release are documented in [CHANGELOG.md](./CHANGELOG.m [`eslint_d`]: https://www.npmjs.com/package/eslint_d [`eslint-loader`]: https://www.npmjs.com/package/eslint-loader [`get-tsconfig`]: https://github.com/privatenumber/get-tsconfig -[`napi-rs`]: https://github.com/napi-rs/napi-rs [`tsconfig-paths`]: https://github.com/dividab/tsconfig-paths [`typescript`]: https://github.com/microsoft/TypeScript [`unrs-resolver`]: https://github.com/unrs/unrs-resolver diff --git a/docs/rules/extensions.md b/docs/rules/extensions.md index 6d56f5f2..e8386799 100644 --- a/docs/rules/extensions.md +++ b/docs/rules/extensions.md @@ -1,5 +1,7 @@ # import-x/extensions +🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + Some file resolve algorithms allow you to omit the file extension within the import source path. For example the `node` resolver (which does not yet support ESM/`import`) can resolve `./foo/bar` to the absolute path `/User/someone/foo/bar.js` because the `.js` extension is resolved automatically by default in CJS. Depending on the resolver you can configure more extensions to get resolved automatically. diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts index ff2975d2..27b3dbef 100644 --- a/src/rules/extensions.ts +++ b/src/rules/extensions.ts @@ -1,6 +1,9 @@ import path from 'node:path' +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema' +import type { RuleFixer } from '@typescript-eslint/utils/ts-eslint' import { minimatch } from 'minimatch' +import type { MinimatchOptions } from 'minimatch' import type { FileExtension, RuleContext } from '../types.js' import { @@ -10,39 +13,41 @@ import { createRule, moduleVisitor, resolve, + parsePath, + stringifyPath, } from '../utils/index.js' const modifierValues = ['always', 'ignorePackages', 'never'] as const const modifierSchema = { - type: 'string' as const, + type: 'string', enum: [...modifierValues], -} +} satisfies JSONSchema4 const modifierByFileExtensionSchema = { - type: 'object' as const, + type: 'object', patternProperties: { '.*': modifierSchema }, -} +} satisfies JSONSchema4 const properties = { - type: 'object' as const, + type: 'object', properties: { pattern: modifierByFileExtensionSchema, ignorePackages: { - type: 'boolean' as const, + type: 'boolean', }, checkTypeImports: { - type: 'boolean' as const, + type: 'boolean', }, pathGroupOverrides: { - type: 'array' as const, + type: 'array', items: { - type: 'object' as const, + type: 'object', properties: { - pattern: { type: 'string' as const }, - patternOptions: { type: 'object' as const }, + pattern: { type: 'string' }, + patternOptions: { type: 'object' }, action: { - type: 'string' as const, + type: 'string', enum: ['enforce', 'ignore'], }, }, @@ -50,8 +55,11 @@ const properties = { required: ['pattern', 'action'], }, }, + fix: { + type: 'boolean', + }, }, -} +} satisfies JSONSchema4 export type Modifier = (typeof modifierValues)[number] @@ -61,25 +69,27 @@ export interface OptionsItemWithPatternProperty { ignorePackages?: boolean checkTypeImports?: boolean pattern: ModifierByFileExtension - fix?: boolean pathGroupOverrides?: PathGroupOverride[] + fix?: boolean } export interface PathGroupOverride { pattern: string - patternOptions?: Record + patternOptions?: Record action: 'enforce' | 'ignore' } export interface OptionsItemWithoutPatternProperty { ignorePackages?: boolean checkTypeImports?: boolean - fix?: boolean pathGroupOverrides?: PathGroupOverride[] + fix?: boolean } export type Options = | [] + | [OptionsItemWithoutPatternProperty] + | [OptionsItemWithPatternProperty] | [Modifier] | [Modifier, OptionsItemWithoutPatternProperty] | [Modifier, OptionsItemWithPatternProperty] @@ -91,11 +101,11 @@ export interface NormalizedOptions { pattern?: Record ignorePackages?: boolean checkTypeImports?: boolean - fix?: boolean pathGroupOverrides?: PathGroupOverride[] + fix?: boolean } -export type MessageId = 'missing' | 'missingKnown' | 'unexpected' +export type MessageId = 'missing' | 'missingKnown' | 'unexpected' | 'addMissing' function buildProperties(context: RuleContext) { const result: Required = { @@ -103,8 +113,8 @@ function buildProperties(context: RuleContext) { pattern: {}, ignorePackages: false, checkTypeImports: false, - fix: false, pathGroupOverrides: [], + fix: false, } for (const obj of context.options) { @@ -120,16 +130,16 @@ function buildProperties(context: RuleContext) { // If this is not the new structure, transfer all props to result.pattern if ( - (!('pattern' in obj) || obj.pattern === undefined) && - obj.ignorePackages === undefined && - obj.checkTypeImports === undefined + (!('pattern' in obj) || obj.pattern == null) && + obj.ignorePackages == null && + obj.checkTypeImports == null ) { Object.assign(result.pattern, obj) continue } // If pattern is provided, transfer all props - if ('pattern' in obj && obj.pattern !== undefined) { + if ('pattern' in obj && obj.pattern != null) { Object.assign(result.pattern, obj.pattern) } @@ -142,11 +152,11 @@ function buildProperties(context: RuleContext) { result.checkTypeImports = obj.checkTypeImports } - if ('fix' in obj) { + if (obj.fix != null) { result.fix = Boolean(obj.fix) } - if ('pathGroupOverrides' in obj && Array.isArray(obj.pathGroupOverrides)) { + if (Array.isArray(obj.pathGroupOverrides)) { result.pathGroupOverrides = obj.pathGroupOverrides } } @@ -167,8 +177,11 @@ function isExternalRootModule(file: string) { return slashCount === 0 || (isScoped(file) && slashCount <= 1) } -function computeOverrideAction(overrides: PathGroupOverride[], path: string) { - for (const { pattern, patternOptions, action } of overrides) { +function computeOverrideAction( + pathGroupOverrides: PathGroupOverride[], + path: string, +) { + for (const { pattern, patternOptions, action } of pathGroupOverrides) { if (minimatch(path, pattern, patternOptions || { nocomment: true })) { return action } @@ -185,6 +198,7 @@ export default createRule({ 'Ensure consistent use of file extension within the import path.', }, fixable: 'code', + hasSuggestions: true, schema: { anyOf: [ { @@ -220,6 +234,8 @@ export default createRule({ 'Missing file extension "{{extension}}" for "{{importPath}}"', unexpected: 'Unexpected use of file extension "{{extension}}" for "{{importPath}}"', + addMissing: + 'Add "{{extension}}" file extension from "{{importPath}}" into "{{fixedImportPath}}"', }, }, defaultOptions: [], @@ -262,11 +278,16 @@ export default createRule({ } 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 + + if (overrideAction === 'ignore') { + return + } // don't enforce anything on builtins if ( @@ -315,6 +336,28 @@ export default createRule({ ) const extensionForbidden = isUseOfExtensionForbidden(extension) if (extensionRequired && !extensionForbidden) { + const { pathname, query, hash } = parsePath( + importPathWithQueryString, + ) + const fixedImportPath = stringifyPath({ + pathname: `${ + /([\\/]|[\\/]?\.?\.)$/.test(pathname) + ? `${ + pathname.endsWith('/') ? pathname.slice(0, -1) : pathname + }/index.${extension}` + : `${pathname}.${extension}` + }`, + query, + hash, + }) + const fixOrSuggest = { + fix(fixer: RuleFixer) { + return fixer.replaceText( + source, + JSON.stringify(fixedImportPath), + ) + }, + } context.report({ node: source, messageId: extension ? 'missingKnown' : 'missing', @@ -322,18 +365,22 @@ export default createRule({ extension, importPath: importPathWithQueryString, }, - ...(props.fix && extension - ? { - fix(fixer) { - return fixer.replaceText( - source, - JSON.stringify( - `${importPathWithQueryString}.${extension}`, - ), - ) - }, - } - : {}), + ...(extension && + (props.fix + ? fixOrSuggest + : { + suggest: [ + { + ...fixOrSuggest, + messageId: 'addMissing', + data: { + extension, + importPath: importPathWithQueryString, + fixedImportPath: fixedImportPath, + }, + }, + ], + })), }) } } else if ( @@ -348,18 +395,14 @@ export default createRule({ extension, importPath: importPathWithQueryString, }, - ...(props.fix - ? { - fix(fixer) { - return fixer.replaceText( - source, - JSON.stringify( - importPath.slice(0, -(extension.length + 1)), - ), - ) - }, - } - : {}), + ...(props.fix && { + fix(fixer) { + return fixer.replaceText( + source, + JSON.stringify(importPath.slice(0, -(extension.length + 1))), + ) + }, + }), }) } }, diff --git a/src/utils/index.ts b/src/utils/index.ts index 2912f44d..02dd8f6e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,6 +15,7 @@ export * from './lazy-value.js' export * from './legacy-resolver-settings.js' export * from './package-path.js' export * from './parse.js' +export * from './parse-path.js' export * from './pkg-dir.js' export * from './pkg-up.js' export * from './read-pkg-up.js' diff --git a/src/utils/parse-path.ts b/src/utils/parse-path.ts new file mode 100644 index 00000000..52d412c9 --- /dev/null +++ b/src/utils/parse-path.ts @@ -0,0 +1,25 @@ +export interface ParsedPath { + pathname: string + query: string + hash: string +} + +export const parsePath = (path: string) => { + const hashIndex = path.indexOf('#') + const queryIndex = path.indexOf('?') + const hasHash = hashIndex !== -1 + const hash = hasHash ? path.slice(hashIndex) : '' + const hasQuery = queryIndex !== -1 && (!hasHash || queryIndex < hashIndex) + const query = hasQuery + ? path.slice(queryIndex, hasHash ? hashIndex : undefined) + : '' + const pathname = hasQuery + ? path.slice(0, queryIndex) + : hasHash + ? path.slice(0, hashIndex) + : path + return { pathname, query, hash } +} + +export const stringifyPath = ({ pathname, query, hash }: ParsedPath) => + pathname + query + hash diff --git a/test/rules/extensions.spec.ts b/test/rules/extensions.spec.ts index ac3800af..989801f4 100644 --- a/test/rules/extensions.spec.ts +++ b/test/rules/extensions.spec.ts @@ -160,6 +160,16 @@ ruleTester.run('extensions', rule, { ].join('\n'), options: ['always'], }), + + tValid({ + code: "import foo from './foo';", + options: [{ fix: true }], + }), + + tValid({ + code: "import foo from './foo.js';", + options: [{ fix: true, pattern: { js: 'always' } }], + }), ], invalid: [ @@ -183,6 +193,17 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: './file.with.dot' }, line: 1, column: 17, + suggestions: [ + { + messageId: 'addMissing', + data: { + extension: 'js', + importPath: './file.with.dot', + fixedImportPath: './file.with.dot.js', + }, + output: 'import dot from "./file.with.dot.js"', + }, + ], }, ], }), @@ -205,6 +226,20 @@ ruleTester.run('extensions', rule, { data: { extension: 'json', importPath: './package' }, line: 2, column: 27, + suggestions: [ + { + messageId: 'addMissing', + data: { + extension: 'json', + importPath: './package', + fixedImportPath: './package.json', + }, + output: [ + 'import a from "a/index.js"', + 'import packageConfig from "./package.json"', + ].join('\n'), + }, + ], }, ], }), @@ -308,12 +343,40 @@ ruleTester.run('extensions', rule, { data: { extension: 'js', importPath: '.' }, line: 1, column: 19, + suggestions: [ + { + messageId: 'addMissing', + data: { + extension: 'js', + importPath: '.', + fixedImportPath: './index.js', + }, + output: [ + 'import barjs from "./index.js"', + 'import barjs2 from ".."', + ].join('\n'), + }, + ], }, { messageId: 'missingKnown', data: { extension: 'js', importPath: '..' }, line: 2, column: 20, + suggestions: [ + { + messageId: 'addMissing', + data: { + extension: 'js', + importPath: '..', + fixedImportPath: '../index.js', + }, + output: [ + 'import barjs from "."', + 'import barjs2 from "../index.js"', + ].join('\n'), + }, + ], }, ], }), @@ -809,6 +872,83 @@ describe('TypeScript', () => { code: 'export type { MyType } from "./typescript-declare.ts";', options: ['always', { checkTypeImports: true }], }), + + // pathGroupOverrides: no patterns match good bespoke specifiers + tValid({ + 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'; + `, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'multiverse{*,*/**}', + action: 'enforce', + }, + ], + }, + ], + }), + // pathGroupOverrides: an enforce pattern matches good bespoke specifiers + tValid({ + 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'; + `, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'rootverse{*,*/**}', + action: 'enforce', + }, + ], + }, + ], + }), + // pathGroupOverrides: an ignore pattern matches bad bespoke specifiers + tValid({ + 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'; + `, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'multiverse{*,*/**}', + action: 'enforce', + }, + { + pattern: 'rootverse{*,*/**}', + action: 'ignore', + }, + ], + }, + ], + }), ], invalid: [ tInvalid({ @@ -831,6 +971,59 @@ describe('TypeScript', () => { ], options: ['always', { checkTypeImports: true }], }), + + // pathGroupOverrides: an enforce pattern matches bad bespoke specifiers + tInvalid({ + 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'; + `, + options: [ + 'always', + { + ignorePackages: true, + checkTypeImports: true, + pathGroupOverrides: [ + { + pattern: 'rootverse{*,*/**}', + action: 'enforce', + }, + { + pattern: 'universe{*,*/**}', + action: 'ignore', + }, + ], + }, + ], + errors: [ + { + messageId: 'missing', + data: { importPath: 'rootverse+debug:src' }, + line: 4, + }, + { + messageId: 'missing', + data: { importPath: 'rootverse+bfe:src/symbols' }, + line: 5, + }, + ], + }), + + tInvalid({ + code: 'import foo from "./foo.js";', + options: ['always', { pattern: { js: 'never' }, fix: true }], + errors: [ + { + messageId: 'unexpected', + data: { extension: 'js', importPath: './foo.js' }, + }, + ], + output: 'import foo from "./foo";', + }), ], }) }) diff --git a/test/utils/__snapshots__/parse-path.spec.ts.snap b/test/utils/__snapshots__/parse-path.spec.ts.snap new file mode 100644 index 00000000..2d55a2b1 --- /dev/null +++ b/test/utils/__snapshots__/parse-path.spec.ts.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parse-path should parse and stringify path expectedly 1`] = ` +{ + "hash": "", + "pathname": "foo", + "query": "", +} +`; + +exports[`parse-path should parse and stringify path expectedly 2`] = ` +{ + "hash": "", + "pathname": "foo", + "query": "?query", +} +`; + +exports[`parse-path should parse and stringify path expectedly 3`] = ` +{ + "hash": "#hash", + "pathname": "foo", + "query": "", +} +`; + +exports[`parse-path should parse and stringify path expectedly 4`] = ` +{ + "hash": "#hash", + "pathname": "foo", + "query": "?query", +} +`; + +exports[`parse-path should parse and stringify path expectedly 5`] = ` +{ + "hash": "#hash?query", + "pathname": "foo", + "query": "", +} +`; diff --git a/test/utils/parse-path.spec.ts b/test/utils/parse-path.spec.ts new file mode 100644 index 00000000..b6b7584c --- /dev/null +++ b/test/utils/parse-path.spec.ts @@ -0,0 +1,19 @@ +import { parsePath, stringifyPath } from 'eslint-plugin-import-x/utils' + +describe('parse-path', () => { + it('should parse and stringify path expectedly', () => { + const cases = [ + 'foo', + 'foo?query', + 'foo#hash', + 'foo?query#hash', + 'foo#hash?query', + ] + + for (const input of cases) { + const output = parsePath(input) + expect(output).toMatchSnapshot() + expect(stringifyPath(output)).toBe(input) + } + }) +}) From 79ab3ac81d3096ae9692308b119950c9405a6162 Mon Sep 17 00:00:00 2001 From: JounQin Date: Sun, 18 May 2025 01:25:56 +0800 Subject: [PATCH 5/6] Create fast-bees-talk.md --- .changeset/fast-bees-talk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fast-bees-talk.md diff --git a/.changeset/fast-bees-talk.md b/.changeset/fast-bees-talk.md new file mode 100644 index 00000000..d745f1fd --- /dev/null +++ b/.changeset/fast-bees-talk.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-import-x": minor +--- + +feat(extensions): support `pathGroupOverrides` and `fix` options From 1474a62315661b4d5770cecef44790e8e96567e4 Mon Sep 17 00:00:00 2001 From: JounQin Date: Sun, 18 May 2025 01:32:32 +0800 Subject: [PATCH 6/6] chore: add explicit return type `ParsedPath` --- src/utils/parse-path.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/parse-path.ts b/src/utils/parse-path.ts index 52d412c9..34991691 100644 --- a/src/utils/parse-path.ts +++ b/src/utils/parse-path.ts @@ -4,7 +4,7 @@ export interface ParsedPath { hash: string } -export const parsePath = (path: string) => { +export const parsePath = (path: string): ParsedPath => { const hashIndex = path.indexOf('#') const queryIndex = path.indexOf('?') const hasHash = hashIndex !== -1