From 0352b1251ec58330991d07eddadfb6f3ce097d31 Mon Sep 17 00:00:00 2001 From: carloProfico <116069365+carloProfico@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:22:07 +0200 Subject: [PATCH 1/3] FIB-1968 Add type imports rule and corresponding tests - Implemented a new ESLint rule to enforce separation of type imports and value imports. - Added utility functions for categorizing and processing imports in . - Created test cases for valid and invalid import scenarios in . - Updated linter configuration to include the new type imports rule. --- src/rules/type-imports.ts | 40 ++++ .../invalid-cases/base.invalid-case.txt | 21 ++ src/tests/type-imports/invalid-cases/index.ts | 5 + src/tests/type-imports/type-imports.test.ts | 28 +++ .../valid-cases/base.valid-case.txt | 22 ++ src/tests/type-imports/valid-cases/index.ts | 5 + src/utils/imports.ts | 218 ++++++++++++++++++ src/utils/linter-config.ts | 4 + 8 files changed, 343 insertions(+) create mode 100644 src/rules/type-imports.ts create mode 100644 src/tests/type-imports/invalid-cases/base.invalid-case.txt create mode 100644 src/tests/type-imports/invalid-cases/index.ts create mode 100644 src/tests/type-imports/type-imports.test.ts create mode 100644 src/tests/type-imports/valid-cases/base.valid-case.txt create mode 100644 src/tests/type-imports/valid-cases/index.ts diff --git a/src/rules/type-imports.ts b/src/rules/type-imports.ts new file mode 100644 index 0000000..f50b23b --- /dev/null +++ b/src/rules/type-imports.ts @@ -0,0 +1,40 @@ +import { Rule } from "eslint"; +import { TSESTree } from "@typescript-eslint/utils"; +import { categorizeImports, processImportGroup } from "../utils/imports"; + +const typeImports: Rule.RuleModule = { + meta: { + fixable: "code", + type: "problem", + messages: { + mixedTypeValueImports: + "Type imports should be separated from value imports within each group.", + }, + docs: { + url: "https://github.com/profico/eslint-plugin-profico#type-imports", + }, + }, + create(context) { + return { + Program(node) { + const importNodes = node.body.filter( + (stmt): stmt is TSESTree.ImportDeclaration => + stmt.type === "ImportDeclaration", + ); + + if (importNodes.length === 0) { + return; + } + + const { absoluteImports, relativeImports } = + categorizeImports(importNodes); + + // Process each group + processImportGroup(absoluteImports, context); + processImportGroup(relativeImports, context); + }, + }; + }, +}; + +export default typeImports; diff --git a/src/tests/type-imports/invalid-cases/base.invalid-case.txt b/src/tests/type-imports/invalid-cases/base.invalid-case.txt new file mode 100644 index 0000000..7e480a1 --- /dev/null +++ b/src/tests/type-imports/invalid-cases/base.invalid-case.txt @@ -0,0 +1,21 @@ +import type * as ReactTypes from 'react'; + +import React, { useState } from 'react'; +import Form, { FormProps, FormSubmitHandler } from 'components/Forms/Form'; +import type Lalalal from 'lalala/lalalal'; +import AddNicknameDialogHeader from 'components/SvgIcons/AddNicknameDialogHeader'; +import useToggleState from 'utils/hooks/useToggleState'; +import useBreakpoint from 'utils/hooks/useBreakpoint'; +import fetch from 'utils/static/fetchRelative'; +import colors from 'styles/themes/colors'; +import { type Kokos } from 'kokos/kokos'; +import { Grid, Button, Typography } from '@mui/material'; +import { PostNicknameData } from 'types/User'; +import { type Kokos } from 'kokos/trokos'; + +import NicknameInput from './NicknameInput'; +import type PreCoolType from './precooltype'; +import SuccessDialog from './SuccessDialog'; +import ConfirmDialog from './ConfirmDialog'; +import { something } from './something'; +import { type barakokula } from './barakokula'; diff --git a/src/tests/type-imports/invalid-cases/index.ts b/src/tests/type-imports/invalid-cases/index.ts new file mode 100644 index 0000000..6890e1e --- /dev/null +++ b/src/tests/type-imports/invalid-cases/index.ts @@ -0,0 +1,5 @@ +import path from "path"; + +import { readAllTxtFilesInDir } from "../../../utils/tests"; + +export default readAllTxtFilesInDir(path.join(__dirname, "."), ["index.ts"]); diff --git a/src/tests/type-imports/type-imports.test.ts b/src/tests/type-imports/type-imports.test.ts new file mode 100644 index 0000000..edd42c7 --- /dev/null +++ b/src/tests/type-imports/type-imports.test.ts @@ -0,0 +1,28 @@ +import { RuleTester } from "eslint"; + +import typeImports from "../../rules/type-imports"; + +import invalidCases from "./invalid-cases"; +import validCases from "./valid-cases"; + +const tester = new RuleTester({ + languageOptions: { + parser: require("@typescript-eslint/parser"), + parserOptions: { + sourceType: "module", + ecmaVersion: 2015, + }, + }, +}); + +tester.run("profico/type-imports", typeImports, { + valid: validCases, + invalid: invalidCases.map((code, index) => ({ + code, + output: validCases[index], + errors: [ + { messageId: "mixedTypeValueImports" }, + { messageId: "mixedTypeValueImports" }, + ], + })), +}); diff --git a/src/tests/type-imports/valid-cases/base.valid-case.txt b/src/tests/type-imports/valid-cases/base.valid-case.txt new file mode 100644 index 0000000..487fc5c --- /dev/null +++ b/src/tests/type-imports/valid-cases/base.valid-case.txt @@ -0,0 +1,22 @@ +import AddNicknameDialogHeader from 'components/SvgIcons/AddNicknameDialogHeader'; +import colors from 'styles/themes/colors'; +import useBreakpoint from 'utils/hooks/useBreakpoint'; +import useToggleState from 'utils/hooks/useToggleState'; +import fetch from 'utils/static/fetchRelative'; +import { Grid, Button, Typography } from '@mui/material'; +import Form, { FormProps, FormSubmitHandler } from 'components/Forms/Form'; +import React, { useState } from 'react'; +import { PostNicknameData } from 'types/User'; + +import type * as ReactTypes from 'react'; +import type Lalalal from 'lalala/lalalal'; +import { type Kokos } from 'kokos/kokos'; +import { type Kokos } from 'kokos/trokos'; + +import ConfirmDialog from './ConfirmDialog'; +import NicknameInput from './NicknameInput'; +import SuccessDialog from './SuccessDialog'; +import { something } from './something'; + +import type PreCoolType from './precooltype'; +import { type barakokula } from './barakokula'; diff --git a/src/tests/type-imports/valid-cases/index.ts b/src/tests/type-imports/valid-cases/index.ts new file mode 100644 index 0000000..6890e1e --- /dev/null +++ b/src/tests/type-imports/valid-cases/index.ts @@ -0,0 +1,5 @@ +import path from "path"; + +import { readAllTxtFilesInDir } from "../../../utils/tests"; + +export default readAllTxtFilesInDir(path.join(__dirname, "."), ["index.ts"]); diff --git a/src/utils/imports.ts b/src/utils/imports.ts index 46f0d28..8cd14d5 100644 --- a/src/utils/imports.ts +++ b/src/utils/imports.ts @@ -8,6 +8,8 @@ import { Node, Statement, } from "estree"; +import { TSESTree } from "@typescript-eslint/utils"; +import type { SourceCode } from "eslint"; type GroupedImportsResult = { start: number; @@ -238,3 +240,219 @@ export function findImportsByPackageName( return importedSet; } + +// Type imports utility functions (for type-imports rule) +export function isTypeImportDeclaration( + importNode: TSESTree.ImportDeclaration, + sourceCode: SourceCode, +): boolean { + const specifiers = importNode.specifiers.filter( + (spec): spec is TSESTree.ImportSpecifier => spec.type === "ImportSpecifier", + ); + + const hasTypeSpecifiers = specifiers.some(spec => spec.importKind === "type"); + + // Check if it's a pure type import (starts with "import type") + const sourceText = sourceCode.getText(importNode); + const isPureTypeImport = /^\s*import\s+type\b/.test(sourceText); + + return isPureTypeImport || hasTypeSpecifiers; +} + +export function isValueImportDeclaration( + importNode: TSESTree.ImportDeclaration, +): boolean { + const specifiers = importNode.specifiers.filter( + (spec): spec is TSESTree.ImportSpecifier => spec.type === "ImportSpecifier", + ); + + const hasValueSpecifiers = specifiers.some( + spec => spec.importKind !== "type", + ); + + // Default imports or namespace imports go with value imports + const hasDefaultOrNamespace = importNode.specifiers.some( + spec => + spec.type === "ImportDefaultSpecifier" || + spec.type === "ImportNamespaceSpecifier", + ); + + return hasValueSpecifiers || hasDefaultOrNamespace || specifiers.length === 0; +} + +export function separateImportsByType( + imports: TSESTree.ImportDeclaration[], + sourceCode: SourceCode, +): { + valueImports: TSESTree.ImportDeclaration[]; + typeImports: TSESTree.ImportDeclaration[]; +} { + const valueImports: TSESTree.ImportDeclaration[] = []; + const typeImports: TSESTree.ImportDeclaration[] = []; + + imports.forEach(importNode => { + if (isTypeImportDeclaration(importNode, sourceCode)) { + typeImports.push(importNode); + } else if (isValueImportDeclaration(importNode)) { + valueImports.push(importNode); + } else { + // Default case: treat as value import + valueImports.push(importNode); + } + }); + + return { valueImports, typeImports }; +} + +export function areImportsInterleaved( + valueImports: TSESTree.ImportDeclaration[], + typeImports: TSESTree.ImportDeclaration[], +): boolean { + if (valueImports.length === 0 || typeImports.length === 0) { + return false; + } + + // Sort imports by their position in the source code + const allImports = [...valueImports, ...typeImports].sort( + (a, b) => a.range![0] - b.range![0], + ); + + // Optimize membership checks using a Set + const typeSet = new Set(typeImports); + + // Check if type and value imports are interleaved + let foundType = false; + for (const importNode of allImports) { + const isTypeImport = typeSet.has(importNode); + + if (isTypeImport) { + foundType = true; + } else if (foundType) { + // Found a value import after a type import - they're interleaved + return true; + } + } + + return false; +} + +// Sort each group alphabetically by source, then by original position for stability +const sortBySource = ( + a: TSESTree.ImportDeclaration, + b: TSESTree.ImportDeclaration, +) => { + const sourceA = a.source.value?.toString() || ""; + const sourceB = b.source.value?.toString() || ""; + const bySource = sourceA.localeCompare(sourceB); + if (bySource !== 0) return bySource; + return (a.range?.[0] ?? 0) - (b.range?.[0] ?? 0); +}; + +export function sortImportsByType( + imports: TSESTree.ImportDeclaration[], +): TSESTree.ImportDeclaration[] { + const namespaceImports: TSESTree.ImportDeclaration[] = []; + const defaultImports: TSESTree.ImportDeclaration[] = []; + const namedImports: TSESTree.ImportDeclaration[] = []; + + imports.forEach(imp => { + const hasNamespaceImport = imp.specifiers.some( + spec => spec.type === "ImportNamespaceSpecifier", + ); + const hasDefaultImport = imp.specifiers.some( + spec => spec.type === "ImportDefaultSpecifier", + ); + const hasNamedImports = imp.specifiers.some( + spec => spec.type === "ImportSpecifier", + ); + + if (hasNamespaceImport) { + namespaceImports.push(imp); + } else if (hasDefaultImport && !hasNamedImports) { + defaultImports.push(imp); + } else { + namedImports.push(imp); + } + }); + + return [ + ...namespaceImports.sort(sortBySource), + ...defaultImports.sort(sortBySource), + ...namedImports.sort(sortBySource), + ]; +} + +export function generateFixedImportsText( + valueImports: TSESTree.ImportDeclaration[], + typeImports: TSESTree.ImportDeclaration[], + sourceCode: SourceCode, +): string { + const sortedValueImports = sortImportsByType(valueImports); + const sortedTypeImports = sortImportsByType(typeImports); + + const valueImportsText = sortedValueImports + .map(imp => sourceCode.getText(imp)) + .join("\n"); + const typeImportsText = sortedTypeImports + .map(imp => sourceCode.getText(imp)) + .join("\n"); + + return valueImportsText + "\n\n" + typeImportsText; +} + +export function getImportRange( + imports: TSESTree.ImportDeclaration[], +): [number, number] { + const startIndex = Math.min(...imports.map(imp => imp.range![0])); + const endIndex = Math.max(...imports.map(imp => imp.range![1])); + return [startIndex, endIndex]; +} + +export function categorizeImports(imports: TSESTree.ImportDeclaration[]): { + absoluteImports: TSESTree.ImportDeclaration[]; + relativeImports: TSESTree.ImportDeclaration[]; +} { + const absoluteImports: TSESTree.ImportDeclaration[] = []; + const relativeImports: TSESTree.ImportDeclaration[] = []; + + imports.forEach(importNode => { + if (isRelativeImport(importNode)) { + relativeImports.push(importNode); + } else { + absoluteImports.push(importNode); + } + }); + + return { absoluteImports, relativeImports }; +} + +export function processImportGroup( + imports: TSESTree.ImportDeclaration[], + context: Rule.RuleContext, +) { + if (imports.length === 0) return; + + const { valueImports, typeImports } = separateImportsByType( + imports, + context.sourceCode, + ); + + if (areImportsInterleaved(valueImports, typeImports)) { + const firstTypeImport = typeImports[0]; + context.report({ + node: firstTypeImport, + messageId: "mixedTypeValueImports", + fix: fixer => { + const allImports = [...valueImports, ...typeImports]; + const [startIndex, endIndex] = getImportRange(allImports); + const replacement = generateFixedImportsText( + valueImports, + typeImports, + context.sourceCode, + ); + + return fixer.replaceTextRange([startIndex, endIndex], replacement); + }, + }); + } +} diff --git a/src/utils/linter-config.ts b/src/utils/linter-config.ts index 47f0c1e..de98ac8 100644 --- a/src/utils/linter-config.ts +++ b/src/utils/linter-config.ts @@ -2,6 +2,7 @@ import { Linter, Rule } from "eslint"; import lodashImports from "../rules/lodash-imports"; import groupedImports from "../rules/grouped-imports"; +import typeImports from "../rules/type-imports"; import recommendedConfig from "../configs/recommended"; import nestConfig from "../configs/nest"; import nextConfig from "../configs/next"; @@ -12,6 +13,7 @@ import orderedControllerParams from "../rules/ordered-controller-params"; type RegularRulesNames = | "lodash-imports" | "grouped-imports" + | "type-imports" | "dto-decorators" | "ordered-controller-params"; type FlatRulesNames = `profico/${RegularRulesNames}`; @@ -29,6 +31,7 @@ export function getRules( return { "profico/lodash-imports": lodashImports, "profico/grouped-imports": groupedImports, + "profico/type-imports": typeImports, "profico/dto-decorators": dtoDecorators, "profico/ordered-controller-params": orderedControllerParams, }; @@ -37,6 +40,7 @@ export function getRules( return { "lodash-imports": lodashImports, "grouped-imports": groupedImports, + "type-imports": typeImports, "dto-decorators": dtoDecorators, "ordered-controller-params": orderedControllerParams, }; From 89d467c92c566a593448d17f14987cd716cc3823 Mon Sep 17 00:00:00 2001 From: carloProfico <116069365+carloProfico@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:38:58 +0200 Subject: [PATCH 2/3] FIB-1968 Refactor import processing and enhance type import handling - Updated the import processing logic to handle absolute and relative imports with appropriate newline management. - Enhanced type import detection in utility functions to improve accuracy. - Added new type imports in test cases for better coverage. --- src/rules/type-imports.ts | 11 +++-- .../invalid-cases/base.invalid-case.txt | 5 ++- .../valid-cases/base.valid-case.txt | 3 +- src/utils/imports.ts | 41 +++++++++++++++++-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/rules/type-imports.ts b/src/rules/type-imports.ts index f50b23b..d34aa3f 100644 --- a/src/rules/type-imports.ts +++ b/src/rules/type-imports.ts @@ -29,9 +29,14 @@ const typeImports: Rule.RuleModule = { const { absoluteImports, relativeImports } = categorizeImports(importNodes); - // Process each group - processImportGroup(absoluteImports, context); - processImportGroup(relativeImports, context); + // Process absolute imports (may need trailing newline if relative imports exist) + processImportGroup( + absoluteImports, + context, + relativeImports.length > 0, + ); + // Process relative imports (never needs trailing newline as it's the last group) + processImportGroup(relativeImports, context, false); }, }; }, diff --git a/src/tests/type-imports/invalid-cases/base.invalid-case.txt b/src/tests/type-imports/invalid-cases/base.invalid-case.txt index 7e480a1..d6e425e 100644 --- a/src/tests/type-imports/invalid-cases/base.invalid-case.txt +++ b/src/tests/type-imports/invalid-cases/base.invalid-case.txt @@ -12,10 +12,13 @@ import { type Kokos } from 'kokos/kokos'; import { Grid, Button, Typography } from '@mui/material'; import { PostNicknameData } from 'types/User'; import { type Kokos } from 'kokos/trokos'; +import type NekiNoviType from './precooltyNekiNoviTypepe'; import NicknameInput from './NicknameInput'; import type PreCoolType from './precooltype'; import SuccessDialog from './SuccessDialog'; import ConfirmDialog from './ConfirmDialog'; import { something } from './something'; -import { type barakokula } from './barakokula'; +import { type barakokula, someValue } from './barakokula'; + + diff --git a/src/tests/type-imports/valid-cases/base.valid-case.txt b/src/tests/type-imports/valid-cases/base.valid-case.txt index 487fc5c..e68d6d5 100644 --- a/src/tests/type-imports/valid-cases/base.valid-case.txt +++ b/src/tests/type-imports/valid-cases/base.valid-case.txt @@ -18,5 +18,6 @@ import NicknameInput from './NicknameInput'; import SuccessDialog from './SuccessDialog'; import { something } from './something'; +import type NekiNoviType from './precooltyNekiNoviTypepe'; import type PreCoolType from './precooltype'; -import { type barakokula } from './barakokula'; +import { type barakokula, someValue } from './barakokula'; diff --git a/src/utils/imports.ts b/src/utils/imports.ts index 8cd14d5..cfa1735 100644 --- a/src/utils/imports.ts +++ b/src/utils/imports.ts @@ -250,13 +250,25 @@ export function isTypeImportDeclaration( (spec): spec is TSESTree.ImportSpecifier => spec.type === "ImportSpecifier", ); + if (importNode.importKind === "type") { + return true; + } + const hasTypeSpecifiers = specifiers.some(spec => spec.importKind === "type"); + if (hasTypeSpecifiers) { + return true; + } + // Check if it's a pure type import (starts with "import type") const sourceText = sourceCode.getText(importNode); const isPureTypeImport = /^\s*import\s+type\b/.test(sourceText); - return isPureTypeImport || hasTypeSpecifiers; + if (isPureTypeImport) { + return true; + } + + return false; } export function isValueImportDeclaration( @@ -266,10 +278,18 @@ export function isValueImportDeclaration( (spec): spec is TSESTree.ImportSpecifier => spec.type === "ImportSpecifier", ); + if (importNode.importKind === "value") { + return true; + } + const hasValueSpecifiers = specifiers.some( spec => spec.importKind !== "type", ); + if (hasValueSpecifiers) { + return true; + } + // Default imports or namespace imports go with value imports const hasDefaultOrNamespace = importNode.specifiers.some( spec => @@ -277,7 +297,15 @@ export function isValueImportDeclaration( spec.type === "ImportNamespaceSpecifier", ); - return hasValueSpecifiers || hasDefaultOrNamespace || specifiers.length === 0; + if (hasDefaultOrNamespace) { + return true; + } + + if (specifiers.length === 0) { + return true; + } + + return false; } export function separateImportsByType( @@ -397,6 +425,7 @@ export function generateFixedImportsText( .map(imp => sourceCode.getText(imp)) .join("\n"); + // Add single blank line between value and type imports return valueImportsText + "\n\n" + typeImportsText; } @@ -429,6 +458,7 @@ export function categorizeImports(imports: TSESTree.ImportDeclaration[]): { export function processImportGroup( imports: TSESTree.ImportDeclaration[], context: Rule.RuleContext, + needsTrailingNewline = false, ) { if (imports.length === 0) return; @@ -451,7 +481,12 @@ export function processImportGroup( context.sourceCode, ); - return fixer.replaceTextRange([startIndex, endIndex], replacement); + // Add trailing newline if this group is followed by other imports + const finalReplacement = needsTrailingNewline + ? replacement + "\n" + : replacement; + + return fixer.replaceTextRange([startIndex, endIndex], finalReplacement); }, }); } From 02728ebb80e291d714014801f1ca54ca6c2e4693 Mon Sep 17 00:00:00 2001 From: carloProfico <116069365+carloProfico@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:46:33 +0200 Subject: [PATCH 3/3] FIB-1968 bummp package json version to 2.0.1-0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1252032..4fa1a7e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "git", "url": "https://github.com/profico/eslint-plugin-profico.git" }, - "version": "2.0.0", + "version": "2.0.1-0", "description": "ESLint plugin and configurations by Profico", "main": "lib/index.js", "scripts": {