From 0b3da273bf977e4ad6cce5323dc9b85ecd421fd2 Mon Sep 17 00:00:00 2001 From: dangreen Date: Fri, 5 Jun 2026 18:05:14 +0400 Subject: [PATCH 1/3] feat(oxlint-config): enhacne rules and plugins --- .../oxlint-config/src/plugin/import-order.js | 74 +++++---- packages/oxlint-config/src/plugin/index.js | 4 +- .../src/plugin/named-import-order.js | 5 +- .../src/plugin/type-import-style.js | 154 ++++++++++++++++++ packages/oxlint-config/src/storybook.js | 5 + .../oxlint-config/src/subconfigs/basic.js | 16 +- .../oxlint-config/src/subconfigs/configs.js | 1 + .../oxlint-config/src/subconfigs/jsdoc.js | 1 + .../oxlint-config/src/subconfigs/react.js | 1 + .../src/subconfigs/react.stylistic.js | 9 +- .../src/subconfigs/typescript-type-checked.js | 1 - .../src/subconfigs/typescript.js | 11 +- packages/oxlint-config/src/test.js | 6 +- 13 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 packages/oxlint-config/src/plugin/type-import-style.js diff --git a/packages/oxlint-config/src/plugin/import-order.js b/packages/oxlint-config/src/plugin/import-order.js index 080f4df..a2a85cc 100644 --- a/packages/oxlint-config/src/plugin/import-order.js +++ b/packages/oxlint-config/src/plugin/import-order.js @@ -161,11 +161,13 @@ function getOptions(context) { } function getImportItems(imports, options) { - return imports.map((node, index) => ({ - index, - node, - rank: getRank(node, options) - })) + return imports + .filter(node => node.specifiers.length > 0) + .map((node, index) => ({ + index, + node, + rank: getRank(node, options) + })) } function compareImportItems(left, right) { @@ -189,23 +191,36 @@ function getFirstUnorderedPair(items) { return null } -function hasInvalidNewlines(items, options) { +function hasBlankLineBetween(sourceCode, left, right) { + return sourceCode.text + .slice(left.node.range[1], right.node.range[0]) + .split(/\r?\n/) + .slice(1, -1) + .some(line => line.trim() === '') +} + +function getInvalidNewlinePair(sourceCode, items, options) { if (options['newlines-between'] === 'ignore') { - return false + return null } - return items.some((item, index) => { - if (index === 0) { - return false - } - + for (let index = 1; index < items.length; index++) { const previousItem = items[index - 1] - const hasEmptyLine = item.node.loc.start.line - previousItem.node.loc.end.line > 1 + const item = items[index] + const hasBlankLine = hasBlankLineBetween(sourceCode, previousItem, item) + const invalid = options['newlines-between'] === 'never' + ? hasBlankLine + : !hasBlankLine + + if (invalid) { + return [ + previousItem, + item + ] + } + } - return options['newlines-between'] === 'never' - ? hasEmptyLine - : !hasEmptyLine - }) + return null } function hasInnerComments(sourceCode, items) { @@ -216,13 +231,8 @@ function hasInnerComments(sourceCode, items) { && comment.range[1] < lastNode.range[1]) } -function hasSideEffectImports(items) { - return items.some(({ node }) => node.specifiers.length === 0) -} - function canFix(sourceCode, items) { - return !hasSideEffectImports(items) - && !hasInnerComments(sourceCode, items) + return !hasInnerComments(sourceCode, items) } function getFixedImportText(sourceCode, items, options) { @@ -316,17 +326,22 @@ export default { imports.push(node) }, - 'Program:exit'(node) { + 'Program:exit'() { if (imports.length < 2) { return } const sourceCode = context.sourceCode const items = getImportItems(imports, options) + + if (items.length < 2) { + return + } + const unorderedPair = getFirstUnorderedPair(items) - const invalidNewlines = hasInvalidNewlines(items, options) + const invalidNewlinePair = getInvalidNewlinePair(sourceCode, items, options) - if (!unorderedPair && !invalidNewlines) { + if (!unorderedPair && !invalidNewlinePair) { return } @@ -339,13 +354,10 @@ export default { const [ previousItem, item - ] = unorderedPair ?? [ - items[0], - items[1] - ] + ] = unorderedPair ?? invalidNewlinePair context.report({ - node, + node: item.node, message: unorderedPair ? getMessage(previousItem.rank, item.rank) : 'Import declarations have invalid empty lines.', diff --git a/packages/oxlint-config/src/plugin/index.js b/packages/oxlint-config/src/plugin/index.js index 2607f72..5101c4c 100644 --- a/packages/oxlint-config/src/plugin/index.js +++ b/packages/oxlint-config/src/plugin/index.js @@ -3,6 +3,7 @@ import importOrderRule from './import-order.js' import memberOrderingRule from './member-ordering.js' import namedImportOrderRule from './named-import-order.js' import namingConventionRule from './naming-convention.js' +import typeImportStyleRule from './type-import-style.js' export default { meta: { @@ -13,6 +14,7 @@ export default { 'import-order': importOrderRule, 'member-ordering': memberOrderingRule, 'named-import-order': namedImportOrderRule, - 'naming-convention': namingConventionRule + 'naming-convention': namingConventionRule, + 'type-import-style': typeImportStyleRule } } diff --git a/packages/oxlint-config/src/plugin/named-import-order.js b/packages/oxlint-config/src/plugin/named-import-order.js index 8fc5745..573bd52 100644 --- a/packages/oxlint-config/src/plugin/named-import-order.js +++ b/packages/oxlint-config/src/plugin/named-import-order.js @@ -39,7 +39,10 @@ function getTypeRank(node, specifier, options) { } function getPatternRank(name, patterns) { - const rank = patterns.findIndex(pattern => new RegExp(pattern).test(name)) + const normalizedName = name.replace(/^\$+|\$+$/g, '') + const rank = patterns.findIndex(pattern => new RegExp(pattern).test( + normalizedName + )) return rank === -1 ? Number.POSITIVE_INFINITY : rank } diff --git a/packages/oxlint-config/src/plugin/type-import-style.js b/packages/oxlint-config/src/plugin/type-import-style.js new file mode 100644 index 0000000..9b1c9ca --- /dev/null +++ b/packages/oxlint-config/src/plugin/type-import-style.js @@ -0,0 +1,154 @@ +function shouldConvert(node) { + return node.importKind !== 'type' + && node.specifiers.length > 0 + && node.specifiers.every(specifier => specifier.type === 'ImportSpecifier' + && specifier.importKind === 'type') +} + +function isNamedImport(node) { + return node.specifiers.length > 0 + && node.specifiers.every(specifier => specifier.type === 'ImportSpecifier') +} + +function isTypeImport(node) { + return node.importKind === 'type' + || ( + isNamedImport(node) + && node.specifiers.every(specifier => specifier.importKind === 'type') + ) +} + +function hasImportAttributes(node) { + return (node.attributes?.length ?? 0) > 0 + || (node.assertions?.length ?? 0) > 0 +} + +function hasCommentsBetween(sourceCode, left, right) { + return sourceCode.getAllComments().some(comment => comment.range[0] > left.range[1] + && comment.range[1] < right.range[0]) +} + +function canMerge(sourceCode, typeNode, valueNode) { + return typeNode.source.value === valueNode.source.value + && isTypeImport(typeNode) + && isNamedImport(typeNode) + && isNamedImport(valueNode) + && !isTypeImport(valueNode) + && !hasImportAttributes(typeNode) + && !hasImportAttributes(valueNode) + && !hasCommentsBetween(sourceCode, typeNode, valueNode) +} + +function getMergePair(imports, sourceCode) { + for (let index = 1; index < imports.length; index++) { + const previousImport = imports[index - 1] + const importNode = imports[index] + + if (canMerge(sourceCode, previousImport, importNode)) { + return [ + previousImport, + importNode + ] + } + } + + return null +} + +function getConvertNode(imports) { + return imports.find(shouldConvert) +} + +function getTypeSpecifierText(specifier, sourceCode) { + const text = sourceCode.getText(specifier) + + return text.startsWith('type ') + ? text + : `type ${text}` +} + +function getMergedText(typeNode, valueNode, sourceCode) { + const typeSpecifiers = typeNode.specifiers.map(specifier => getTypeSpecifierText( + specifier, + sourceCode + )) + const valueSpecifiers = valueNode.specifiers.map(specifier => sourceCode.getText( + specifier + )) + const source = sourceCode.getText(valueNode.source) + + return `import { ${[ + ...typeSpecifiers, + ...valueSpecifiers + ].join(', ')} } from ${source}` +} + +function getFixedText(node, sourceCode) { + return sourceCode.getText(node) + .replace(/^import\b/, 'import type') + .replace(/([,{]\s*)type\s+/g, '$1') +} + +export default { + meta: { + type: 'layout', + fixable: 'code', + docs: { + description: 'Prefer import type and merge duplicate type/value imports.' + }, + schema: [] + }, + create(context) { + const sourceCode = context.sourceCode + const imports = [] + + return { + ImportDeclaration(node) { + if (typeof node.source.value !== 'string') { + return + } + + imports.push(node) + }, + 'Program:exit'() { + const mergePair = getMergePair(imports, sourceCode) + + if (mergePair) { + const [ + typeNode, + valueNode + ] = mergePair + + context.report({ + node: valueNode.source, + message: 'Merge type and value imports from the same source.', + fix: fixer => fixer.replaceTextRange( + [ + typeNode.range[0], + valueNode.range[1] + ], + getMergedText(typeNode, valueNode, sourceCode) + ) + }) + + return + } + + const convertNode = getConvertNode(imports) + + if (!convertNode) { + return + } + + context.report({ + node: convertNode.source, + message: 'Use import type when all named imports are type imports.', + fix: fixer => fixer.replaceTextRange( + convertNode.range, + getFixedText(convertNode, sourceCode) + ) + }) + } + } + } +} diff --git a/packages/oxlint-config/src/storybook.js b/packages/oxlint-config/src/storybook.js index 8df3d56..58eb649 100644 --- a/packages/oxlint-config/src/storybook.js +++ b/packages/oxlint-config/src/storybook.js @@ -8,6 +8,11 @@ export default { overrides: [ { files: storiesFiles, + plugins: [ + 'import', + 'react', + 'typescript' + ], rules: { 'eslint/max-classes-per-file': 'off', 'eslint/no-magic-numbers': 'off', diff --git a/packages/oxlint-config/src/subconfigs/basic.js b/packages/oxlint-config/src/subconfigs/basic.js index 717cc86..6190a11 100644 --- a/packages/oxlint-config/src/subconfigs/basic.js +++ b/packages/oxlint-config/src/subconfigs/basic.js @@ -161,7 +161,12 @@ export default { 'eslint/no-useless-call': 'error', 'eslint/no-useless-concat': 'error', 'eslint/no-useless-return': 'error', - 'eslint/no-void': 'error', + 'eslint/no-void': [ + 'off', + { + allowAsStatement: true + } + ], 'eslint/prefer-promise-reject-errors': 'error', 'eslint/prefer-regex-literals': 'error', 'eslint/preserve-caught-error': 'error', @@ -276,15 +281,18 @@ export default { }, { selector: 'typeLike', - format: ['PascalCase'] + format: ['PascalCase'], + trailingDollar: 'allow' }, { selector: 'interface', - format: ['PascalCase'] + format: ['PascalCase'], + trailingDollar: 'allow' }, { selector: 'enumMember', - format: ['PascalCase'] + format: ['PascalCase'], + trailingDollar: 'allow' }, { selector: 'classProperty', diff --git a/packages/oxlint-config/src/subconfigs/configs.js b/packages/oxlint-config/src/subconfigs/configs.js index acebb61..5bd9443 100644 --- a/packages/oxlint-config/src/subconfigs/configs.js +++ b/packages/oxlint-config/src/subconfigs/configs.js @@ -8,6 +8,7 @@ export default { overrides: [ { files: configFiles, + plugins: ['import'], rules: { 'import/no-default-export': 'off', 'import/no-anonymous-default-export': 'off' diff --git a/packages/oxlint-config/src/subconfigs/jsdoc.js b/packages/oxlint-config/src/subconfigs/jsdoc.js index 6939c03..e1ca2da 100644 --- a/packages/oxlint-config/src/subconfigs/jsdoc.js +++ b/packages/oxlint-config/src/subconfigs/jsdoc.js @@ -32,6 +32,7 @@ export default { overrides: [ { files: tsFiles, + plugins: ['jsdoc'], rules: { 'jsdoc/require-param': 'off', 'jsdoc/require-yields-type': 'off' diff --git a/packages/oxlint-config/src/subconfigs/react.js b/packages/oxlint-config/src/subconfigs/react.js index 0e32ea6..3c0cc8b 100644 --- a/packages/oxlint-config/src/subconfigs/react.js +++ b/packages/oxlint-config/src/subconfigs/react.js @@ -11,6 +11,7 @@ export default { overrides: [ { files: jsxFiles, + plugins: ['jsdoc'], rules: { 'jsdoc/require-param': 'off', 'jsdoc/require-returns': 'off' diff --git a/packages/oxlint-config/src/subconfigs/react.stylistic.js b/packages/oxlint-config/src/subconfigs/react.stylistic.js index ec0bcf3..6df6d7f 100644 --- a/packages/oxlint-config/src/subconfigs/react.stylistic.js +++ b/packages/oxlint-config/src/subconfigs/react.stylistic.js @@ -19,7 +19,14 @@ export default { 'stylistic-js/jsx-child-element-spacing': 'error', 'stylistic-js/jsx-closing-bracket-location': 'error', 'stylistic-js/jsx-closing-tag-location': 'error', - 'react/jsx-curly-brace-presence': ['error', 'never'], + 'react/jsx-curly-brace-presence': [ + 'error', + { + children: 'never', + propElementValues: 'always', + props: 'never' + } + ], 'stylistic-js/jsx-curly-newline': 'error', 'stylistic-js/jsx-curly-spacing': 'error', 'stylistic-js/jsx-equals-spacing': 'error', diff --git a/packages/oxlint-config/src/subconfigs/typescript-type-checked.js b/packages/oxlint-config/src/subconfigs/typescript-type-checked.js index 1939b4b..60dba98 100644 --- a/packages/oxlint-config/src/subconfigs/typescript-type-checked.js +++ b/packages/oxlint-config/src/subconfigs/typescript-type-checked.js @@ -53,7 +53,6 @@ export default { 'typescript/no-unnecessary-qualifier': 'error', 'typescript/no-unnecessary-template-expression': 'error', 'typescript/no-unnecessary-type-arguments': 'error', - 'typescript/consistent-type-imports': 'error', 'typescript/prefer-includes': 'error', 'typescript/prefer-nullish-coalescing': 'off', 'typescript/prefer-optional-chain': 'error', diff --git a/packages/oxlint-config/src/subconfigs/typescript.js b/packages/oxlint-config/src/subconfigs/typescript.js index 9106c6d..2dbb7b0 100644 --- a/packages/oxlint-config/src/subconfigs/typescript.js +++ b/packages/oxlint-config/src/subconfigs/typescript.js @@ -68,13 +68,21 @@ export default { fixMixedExportsWithInlineTypeSpecifier: true } ], + 'typescript/consistent-type-imports': 'error', 'typescript/explicit-module-boundary-types': 'off', 'typescript/no-dynamic-delete': 'error', 'typescript/no-extraneous-class': 'error', - 'typescript/no-invalid-void-type': 'error', + 'typescript/no-invalid-void-type': [ + 'error', + { + allowAsThisParameter: true, + allowInGenericTypeArguments: true + } + ], 'typescript/prefer-for-of': 'error', 'typescript/prefer-function-type': 'error', 'typescript/unified-signatures': 'error', + 'trigen/type-import-style': 'error', 'trigen/member-ordering': [ 'error', { @@ -108,6 +116,7 @@ export default { }, { files: dtsFiles, + plugins: ['import'], rules: { 'import/unambiguous': 'off' } diff --git a/packages/oxlint-config/src/test.js b/packages/oxlint-config/src/test.js index eb773b4..4bc40e9 100644 --- a/packages/oxlint-config/src/test.js +++ b/packages/oxlint-config/src/test.js @@ -11,6 +11,7 @@ export default { env: { vitest: true }, + plugins: ['typescript'], rules: { 'eslint/max-classes-per-file': 'off', 'eslint/no-magic-numbers': 'off', @@ -25,7 +26,10 @@ export default { 'trigen/import-order': 'off', 'eslint/prefer-destructuring': 'off', 'eslint/no-loop-func': 'off', - 'typescript/no-misused-promises': 'off' + 'typescript/no-misused-promises': 'off', + 'eslint/no-use-before-define': 'off', + 'eslint/no-useless-assignment': 'off', + 'eslint/no-empty-function': 'off' // Unsupported by Oxlint // 'eslint/camelcase': 'off', From d25374a9af096cb7203b18b1c2d01fa64a5cf100 Mon Sep 17 00:00:00 2001 From: dangreen Date: Fri, 5 Jun 2026 18:25:45 +0400 Subject: [PATCH 2/3] feat(oxlint-config): review fixes --- packages/oxlint-config/src/plugin/import-order.js | 13 +++++++++++++ .../oxlint-config/src/plugin/type-import-style.js | 12 ++++++++++++ packages/oxlint-config/src/subconfigs/basic.js | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/oxlint-config/src/plugin/import-order.js b/packages/oxlint-config/src/plugin/import-order.js index a2a85cc..a143cb3 100644 --- a/packages/oxlint-config/src/plugin/import-order.js +++ b/packages/oxlint-config/src/plugin/import-order.js @@ -235,6 +235,18 @@ function canFix(sourceCode, items) { return !hasInnerComments(sourceCode, items) } +function hasSkippedImportsBetween(imports, items) { + const itemNodes = new Set(items.map(({ node }) => node)) + const [ + start, + end + ] = getImportBlockRange(items) + + return imports.some(node => !itemNodes.has(node) + && node.range[0] > start + && node.range[1] < end) +} + function getFixedImportText(sourceCode, items, options) { const linebreak = getLinebreak(sourceCode.text) const separator = options['newlines-between'] === 'always' @@ -346,6 +358,7 @@ export default { } const fix = canFix(sourceCode, items) + && !hasSkippedImportsBetween(imports, items) ? fixer => fixer.replaceTextRange( getImportBlockRange(items), getFixedImportText(sourceCode, items, options) diff --git a/packages/oxlint-config/src/plugin/type-import-style.js b/packages/oxlint-config/src/plugin/type-import-style.js index 9b1c9ca..2bc8dd2 100644 --- a/packages/oxlint-config/src/plugin/type-import-style.js +++ b/packages/oxlint-config/src/plugin/type-import-style.js @@ -1,5 +1,6 @@ function shouldConvert(node) { return node.importKind !== 'type' + && !hasImportAttributes(node) && node.specifiers.length > 0 && node.specifiers.every(specifier => specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') @@ -28,6 +29,16 @@ function hasCommentsBetween(sourceCode, left, right) { && comment.range[1] < right.range[0]) } +function getLocalName(specifier) { + return specifier.local?.name ?? null +} + +function hasOverlappingLocalNames(left, right) { + const leftNames = new Set(left.specifiers.map(getLocalName)) + + return right.specifiers.some(specifier => leftNames.has(getLocalName(specifier))) +} + function canMerge(sourceCode, typeNode, valueNode) { return typeNode.source.value === valueNode.source.value && isTypeImport(typeNode) @@ -36,6 +47,7 @@ function canMerge(sourceCode, typeNode, valueNode) { && !isTypeImport(valueNode) && !hasImportAttributes(typeNode) && !hasImportAttributes(valueNode) + && !hasOverlappingLocalNames(typeNode, valueNode) && !hasCommentsBetween(sourceCode, typeNode, valueNode) } diff --git a/packages/oxlint-config/src/subconfigs/basic.js b/packages/oxlint-config/src/subconfigs/basic.js index 6190a11..c5c3e3b 100644 --- a/packages/oxlint-config/src/subconfigs/basic.js +++ b/packages/oxlint-config/src/subconfigs/basic.js @@ -162,7 +162,7 @@ export default { 'eslint/no-useless-concat': 'error', 'eslint/no-useless-return': 'error', 'eslint/no-void': [ - 'off', + 'error', { allowAsStatement: true } From 1d088a4851b0f04839d8bc7f011ae877c95dd71e Mon Sep 17 00:00:00 2001 From: dangreen Date: Fri, 5 Jun 2026 18:39:21 +0400 Subject: [PATCH 3/3] feat(oxlint-config): review fixes --- packages/oxlint-config/src/plugin/import-order.js | 7 +++++-- packages/oxlint-config/src/test.js | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/oxlint-config/src/plugin/import-order.js b/packages/oxlint-config/src/plugin/import-order.js index a143cb3..2e7e2ab 100644 --- a/packages/oxlint-config/src/plugin/import-order.js +++ b/packages/oxlint-config/src/plugin/import-order.js @@ -350,15 +350,18 @@ export default { return } + const hasSkippedImports = hasSkippedImportsBetween(imports, items) const unorderedPair = getFirstUnorderedPair(items) - const invalidNewlinePair = getInvalidNewlinePair(sourceCode, items, options) + const invalidNewlinePair = hasSkippedImports + ? null + : getInvalidNewlinePair(sourceCode, items, options) if (!unorderedPair && !invalidNewlinePair) { return } const fix = canFix(sourceCode, items) - && !hasSkippedImportsBetween(imports, items) + && !hasSkippedImports ? fixer => fixer.replaceTextRange( getImportBlockRange(items), getFixedImportText(sourceCode, items, options) diff --git a/packages/oxlint-config/src/test.js b/packages/oxlint-config/src/test.js index 4bc40e9..ef17f14 100644 --- a/packages/oxlint-config/src/test.js +++ b/packages/oxlint-config/src/test.js @@ -12,6 +12,7 @@ export default { vitest: true }, plugins: ['typescript'], + jsPlugins: ['@trigen/oxlint-config/plugin'], rules: { 'eslint/max-classes-per-file': 'off', 'eslint/no-magic-numbers': 'off',