├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.mjs ├── .prettierrc.js ├── LICENSE ├── README.md ├── commitlint.config.ts ├── docs └── rules │ └── import-alias.md ├── eslint.config.mjs ├── examples ├── .gitignore ├── .lintstagedrc.mjs ├── barrel │ ├── .eslintrc.js │ ├── package.json │ ├── src │ │ ├── baz.ts │ │ ├── foo.ts │ │ └── index.ts │ └── tsconfig.json ├── basic │ ├── .eslintrc.js │ ├── package.json │ ├── src │ │ ├── foo.ts │ │ ├── index.ts │ │ └── potato.ts │ └── tsconfig.json ├── nested-configs │ ├── package.json │ ├── src │ │ ├── .eslintrc.js │ │ ├── foo.ts │ │ ├── index.ts │ │ └── tsconfig.json │ ├── test │ │ ├── .eslintrc.js │ │ ├── foo.ts │ │ ├── index.ts │ │ └── tsconfig.json │ └── tsconfig.json └── specific-tsconfig │ ├── .eslintrc.js │ ├── configs │ └── tsconfig.preferred.json │ ├── package.json │ ├── src │ ├── foo.ts │ └── index.ts │ └── tsconfig.json ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── alias-config.ts ├── index.ts └── rules │ └── import-alias.ts ├── tests └── rules │ ├── import-alias.config.ts │ ├── import-alias.invalid-cases.ts │ ├── import-alias.test.ts │ └── import-alias.valid-cases.ts ├── tsconfig.build.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .vim 2 | build 3 | node_modules 4 | coverage 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | "*.{js,json,ts}": "eslint", 3 | "*.{js,json,ts,md}": "prettier --write", 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("prettier").Config} PrettierConfig 3 | */ 4 | const prettierConfig = { 5 | tabWidth: 4, 6 | }; 7 | 8 | module.exports = prettierConfig; 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 James Ni 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @limegrass/eslint-plugin-import-alias 2 | 3 | Encourage use of defined aliases in TSConfig/JSConfig through ESLint. 4 | 5 | ## Why 6 | 7 | - Automatic imports by tsserver resolve to relative paths that can be normalized. 8 | - It's easier to refactor by finding and replacing an absolute module path 9 | without worrying about crafting the regex for `../` and `./` 10 | 11 | ## Requirements 12 | 13 | - Node 14+ 14 | 15 | ## Install 16 | 17 | ```shell 18 | npm install --save-dev @limegrass/eslint-plugin-import-alias eslint 19 | ``` 20 | 21 | This plugin relies on an alias configuration in `tsconfig.json`, `jsconfig.json`, 22 | or a config with the same schema and a path given as `aliasConfigPath` in its rules 23 | settings. See the [rules documentation][docs-import-alias] for more detail. 24 | 25 | ## Configuration 26 | 27 | The following is the most basic configuration. 28 | Check the [rules documentation][docs-import-alias] for further configuration. 29 | 30 | ```jsonc 31 | // .eslintrc 32 | { 33 | "plugins": ["@limegrass/import-alias"], 34 | "rules": { 35 | "@limegrass/import-alias/import-alias": "error", 36 | }, 37 | } 38 | ``` 39 | 40 | The configuration above is also equivalent to 41 | 42 | ```jsonc 43 | // .eslintrc 44 | { 45 | "extends": [ 46 | // ... - your other extends, such as `"eslint:recommended"` 47 | "plugin:@limegrass/import-alias/recommended", 48 | ], 49 | } 50 | ``` 51 | 52 | [docs-import-alias]: docs/rules/import-alias.md 53 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-default-export 2 | export default { 3 | extends: ["@commitlint/config-conventional"], 4 | }; 5 | -------------------------------------------------------------------------------- /docs/rules/import-alias.md: -------------------------------------------------------------------------------- 1 | # import-alias/import-alias: Encourage use of defined aliases 2 | 3 | ## Rule Details 4 | 5 | Given the following `tsconfig.json` 6 | 7 | ```jsonc 8 | { 9 | // ... 10 | "compilerOptions": { 11 | // ... 12 | "paths": { 13 | "#src/*": ["src/*"], 14 | "#rules/*": ["src/rules/*"], 15 | }, 16 | }, 17 | } 18 | ``` 19 | 20 | ### Valid 21 | 22 | ```ts 23 | import { Potato } from "#src/potato"; 24 | import { garbage } from "#rules/garbage"; 25 | import { Foo } from "external-module"; 26 | 27 | require("#src/potato"); 28 | jest.mock("#rules/garbage"); 29 | ``` 30 | 31 | ### Invalid 32 | 33 | Given a file in `./src` 34 | 35 | ```ts 36 | import { Potato } from "./potato"; // relative paths are not okay 37 | import { garbage } from "#src/rules/garbage"; // import can be shortened 38 | 39 | require("./potato"); 40 | jest.mock("#src/rules/garbage"); 41 | ``` 42 | 43 | ## Configuration 44 | 45 | To add new functions which takes a single string parameter, 46 | you can define your own functions that are considered import functions. 47 | For example, defining the following in .eslintrc will check the first 48 | parameter of a function named `potato` for aliasing. 49 | 50 | ```jsonc 51 | { 52 | // ... 53 | "rules": { 54 | // ... 55 | "@limegrass/import-alias/import-alias": [ 56 | "error", 57 | { 58 | "aliasImportFunctions": ["require", "mock", "potato"], 59 | }, 60 | ], 61 | }, 62 | } 63 | ``` 64 | 65 | This was based off an assumption that custom mock functions will take a similar 66 | parameter to `require` or `jest.mock`, but please submit an issue detailing 67 | your usage if this does not serve your needs. 68 | 69 | ### Specifying TSConfig 70 | 71 | The relative path to `tsconfig.json` can be explicitly specified using the `aliasConfigPath` 72 | configuration key. An example for a tsconfig found in a `config` folder of a project could be 73 | 74 | ```jsonc 75 | { 76 | // ... 77 | "rules": { 78 | // ... 79 | "@limegrass/import-alias/import-alias": [ 80 | "error", 81 | { 82 | "aliasConfigPath": "config/tsconfig.json", 83 | }, 84 | ], 85 | }, 86 | } 87 | ``` 88 | 89 | One potentially useful case of this is where you have nested `tsconfig.json` files. 90 | You can the `aliasConfigPath` option with the `__dirname` variable in JavaScript ESLint config files 91 | to configure some dynamic roots against different project roots. 92 | See [this issue](https://github.com/Limegrass/eslint-plugin-import-alias/issues/15#issuecomment-1998548874) for an example usage. 93 | 94 | ### Enabling relative import overrides 95 | 96 | #### Path-based overrides 97 | 98 | It is possible to allow relative imports for some paths if desires through configuring 99 | a `relativeImportOverrides` configuration parameter on the rule. Each configuration requires 100 | a path and a depth to be specified, where a depth of `0` allows imports of sibling modules, 101 | a depth of `1` allows imports from siblings of the parent module, and so on. 102 | 103 | The follow example would allow sibling imports for the entire project. 104 | 105 | ```jsonc 106 | { 107 | // ... 108 | "rules": { 109 | // ... 110 | "@limegrass/import-alias/import-alias": [ 111 | "error", 112 | { 113 | "relativeImportOverrides": [{ "path": ".", "depth": 0 }], 114 | }, 115 | ], 116 | }, 117 | } 118 | ``` 119 | 120 | With a configuration like `{ path: "src/foo", depth: 0 }` 121 | 122 | 1. Relative paths can be used in `./src/foo`. 123 | 2. Relative paths can be used in `./src/foo/bar`. 124 | 3. Relative paths can NOT be used in `./src`. 125 | 126 | With a configuration like `{ path: "src", depth: 0 }` 127 | 128 | 1. Relative paths can be used in `./src/foo`. 129 | 2. Relative paths can be used in `./src/bar/baz`. 130 | 3. Relative paths can be used in `./src`. 131 | 132 | In `./src/foo` with `path: "src"` 133 | 134 | 1. `import "./bar"` for `./src/bar` when `depth` \>= `0`. 135 | 2. `import "./bar/baz"` when `depth` \>= `0`. 136 | 3. `import "../bar"` when `depth` \>= `1`. 137 | 138 | #### Pattern-based overrides 139 | 140 | Regular expression patterns serve as a potential alternative to path overrides. 141 | 142 | With a configuration like `{ pattern: "index.ts", depth: 0 }` 143 | 144 | 1. Relative paths can be used in files such as `./src/index.ts`. 145 | 1. Relative paths can be used any file in the folder `./src/index.ts/*`. 146 | 1. Relative paths can NOT be used in files such as `./src/foo.ts`. 147 | 148 | With a configuration like `{ pattern: "index\\.(ts|js)$" depth: 0 }` 149 | 150 | 1. Relative paths can be used in any file that ends with `index.js` or `index.ts`. 151 | 1. Relative paths can be NOT in the folder `./src/index.ts/*`. 152 | 1. Relative paths can be NOT used in `./src/foo.ts`. 153 | 154 | If a file matches by both patterns and paths, the maximum depth allowed is simply 155 | the largest of all matched overrides. 156 | 157 | ### Configuring TSConfig's `baseUrl`-based resolution 158 | 159 | By default, TypeScript can resolve your modules based off absolute paths from 160 | the `baseUrl` defined in `tsconfig.json`. This means that if your TSConfig looks like 161 | 162 | ```jsonc 163 | { 164 | // ... 165 | "compilerOptions": { 166 | // ... 167 | "baseUrl": ".", 168 | "paths": { 169 | "#src/*": ["src/*"], 170 | }, 171 | }, 172 | } 173 | ``` 174 | 175 | then when trying to import a file `src/potato.ts` 176 | 177 | ```typescript 178 | import { Potato } from "src/potato"; // valid as it is resolved through TypeScript's baseUrl as `./src/potato` 179 | import { Potato } from "#src/potato"; // also valid as it uses a defined path to resolve it 180 | ``` 181 | 182 | You can choose whether or not these types of absolute paths which do not use an 183 | alias are acceptable in your project by configuring the `isAllowBaseUrlResolvedImport` 184 | plug-in option for this rule. 185 | 186 | ```jsonc 187 | { 188 | // ... 189 | "rules": { 190 | // ... 191 | "@limegrass/import-alias/import-alias": [ 192 | "error", 193 | { 194 | "isAllowBaseUrlResolvedImport": false, 195 | }, 196 | ], 197 | }, 198 | } 199 | ``` 200 | 201 | This value is `false` by default in the recommended config to encourage being as explicit 202 | as possible with path definitions. When this value is set to `false`, 203 | the path defined through the `baseUrl` is not considered valid. 204 | 205 | ```typescript 206 | import { Potato } from "src/potato"; // now invalid 207 | import { Potato } from "#src/potato"; // valid as expected 208 | ``` 209 | 210 | While another suggestion could be to assign no paths and use only the `baseUrl`-resolved paths; 211 | this does not currently function when this is the case. If this is your preference and this is 212 | not yet implemented, feel free to file a new issue. 213 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; 2 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 3 | import tsdoc from "eslint-plugin-tsdoc"; 4 | import _import from "eslint-plugin-import"; 5 | import globals from "globals"; 6 | import tsParser from "@typescript-eslint/parser"; 7 | import path from "node:path"; 8 | import { fileURLToPath } from "node:url"; 9 | import js from "@eslint/js"; 10 | import { FlatCompat } from "@eslint/eslintrc"; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all, 18 | }); 19 | 20 | // eslint-disable-next-line import/no-default-export 21 | export default [ 22 | ...fixupConfigRules( 23 | compat.extends( 24 | "eslint:recommended", 25 | "plugin:@typescript-eslint/eslint-recommended", 26 | "plugin:@typescript-eslint/recommended", 27 | "plugin:import/recommended", 28 | "plugin:import/typescript", 29 | "plugin:@limegrass/import-alias/recommended", 30 | "prettier", 31 | ), 32 | ), 33 | { 34 | plugins: { 35 | "@typescript-eslint": fixupPluginRules(typescriptEslint), 36 | tsdoc, 37 | import: fixupPluginRules(_import), 38 | }, 39 | 40 | languageOptions: { 41 | globals: { 42 | ...globals.node, 43 | }, 44 | 45 | parser: tsParser, 46 | }, 47 | 48 | settings: { 49 | "import/parsers": { 50 | "@typescript-eslint/parser": [".ts"], 51 | }, 52 | 53 | "import/resolver": { 54 | typescript: {}, 55 | }, 56 | }, 57 | 58 | rules: { 59 | "import/no-default-export": "error", 60 | "tsdoc/syntax": "warn", 61 | }, 62 | }, 63 | ]; 64 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /examples/.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | "*.{js,json,ts,md}": "prettier --write", 3 | }; 4 | -------------------------------------------------------------------------------- /examples/barrel/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | parser: "@typescript-eslint/parser", 7 | plugins: ["@typescript-eslint", "@limegrass/import-alias"], 8 | root: true, 9 | rules: { 10 | "@limegrass/import-alias/import-alias": [ 11 | "error", 12 | { 13 | relativeImportOverrides: [ 14 | { 15 | pattern: "[\\/]index\\.[jt]sx?$", 16 | depth: 0, 17 | }, 18 | ], 19 | }, 20 | ], 21 | }, 22 | settings: { 23 | "import/parsers": { 24 | "@typescript-eslint/parser": [".ts"], 25 | }, 26 | "import/resolver": { 27 | typescript: {}, 28 | }, 29 | }, 30 | }; 31 | 32 | module.exports = config; 33 | -------------------------------------------------------------------------------- /examples/barrel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "build/index.js", 3 | "scripts": { 4 | "build": "tsc", 5 | "prepare": "npm run build" 6 | }, 7 | "devDependencies": { 8 | "@types/node": "^20", 9 | "@typescript-eslint/eslint-plugin": "^5", 10 | "@typescript-eslint/parser": "^5", 11 | "eslint": "^8", 12 | "eslint-import-resolver-typescript": "^3", 13 | "@limegrass/eslint-plugin-import-alias": "file:../../build", 14 | "typescript": "^5" 15 | }, 16 | "name": "@limegrass/eslint-plugin-import-alias.example.barrel", 17 | "version": "1.0.0", 18 | "description": "Allow usage of relative imports in barrel files (index.ts re-exports)", 19 | "author": "limegrass", 20 | "license": "MIT" 21 | } 22 | -------------------------------------------------------------------------------- /examples/barrel/src/baz.ts: -------------------------------------------------------------------------------- 1 | import { bar } from "./foo"; // error: import ./foo can be written as #src/foo 2 | 3 | export const baz = bar + "baz"; 4 | -------------------------------------------------------------------------------- /examples/barrel/src/foo.ts: -------------------------------------------------------------------------------- 1 | export const bar = "bar"; 2 | -------------------------------------------------------------------------------- /examples/barrel/src/index.ts: -------------------------------------------------------------------------------- 1 | export { bar } from "./foo"; // Relative import is valid 2 | -------------------------------------------------------------------------------- /examples/barrel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/*", "public/*"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "outDir": "./build", 9 | "paths": { 10 | "#src/*": ["src/*"], 11 | "#root/*": ["*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/basic/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ["plugin:@limegrass/import-alias/recommended"], 7 | parser: "@typescript-eslint/parser", 8 | plugins: ["@typescript-eslint", "@limegrass/import-alias"], 9 | root: true, 10 | settings: { 11 | "import/parsers": { 12 | "@typescript-eslint/parser": [".ts"], 13 | }, 14 | "import/resolver": { 15 | typescript: {}, 16 | }, 17 | }, 18 | }; 19 | 20 | module.exports = config; 21 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "build/index.js", 3 | "scripts": { 4 | "build": "tsc" 5 | }, 6 | "devDependencies": { 7 | "@types/node": "^20", 8 | "@typescript-eslint/eslint-plugin": "^5", 9 | "@typescript-eslint/parser": "^5", 10 | "eslint": "^8", 11 | "eslint-import-resolver-typescript": "^3", 12 | "@limegrass/eslint-plugin-import-alias": "file:../../build", 13 | "typescript": "^5" 14 | }, 15 | "name": "@limegrass/eslint-plugin-import-alias.example.basic", 16 | "version": "1.0.0", 17 | "description": "Basic example usage of @limegrass/eslint-plugin-import-alias", 18 | "author": "limegrass", 19 | "license": "MIT" 20 | } 21 | -------------------------------------------------------------------------------- /examples/basic/src/foo.ts: -------------------------------------------------------------------------------- 1 | export const bar = "bar"; 2 | -------------------------------------------------------------------------------- /examples/basic/src/index.ts: -------------------------------------------------------------------------------- 1 | import { bar } from "./foo"; // error: import ./foo can be written as #src/foo 2 | export * from "src/potato"; // error: import src/potato can be written as #src/potato 3 | 4 | export const baz = bar + "baz"; 5 | -------------------------------------------------------------------------------- /examples/basic/src/potato.ts: -------------------------------------------------------------------------------- 1 | export const potato = "potato"; 2 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/*", "public/*"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "outDir": "./build", 9 | "paths": { 10 | "#src/*": ["src/*"], 11 | "#root/*": ["*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/nested-configs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "build/index.js", 3 | "scripts": { 4 | "build": "tsc", 5 | "prepare": "npm run build" 6 | }, 7 | "devDependencies": { 8 | "@types/node": "^20", 9 | "@typescript-eslint/eslint-plugin": "^5", 10 | "@typescript-eslint/parser": "^5", 11 | "eslint": "^8", 12 | "eslint-import-resolver-typescript": "^3", 13 | "@limegrass/eslint-plugin-import-alias": "file:../../build", 14 | "typescript": "^5" 15 | }, 16 | "name": "@limegrass/eslint-plugin-import-alias.example.nested-configs", 17 | "version": "1.0.0", 18 | "description": "Separate ESLint/TSConfig configurations per directory - note that TSConfig sourcing overwrites key-value pairs", 19 | "author": "limegrass", 20 | "license": "MIT" 21 | } 22 | -------------------------------------------------------------------------------- /examples/nested-configs/src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ["plugin:@limegrass/import-alias/recommended"], 7 | parser: "@typescript-eslint/parser", 8 | plugins: ["@typescript-eslint", "@limegrass/import-alias"], 9 | root: true, 10 | settings: { 11 | "import/parsers": { 12 | "@typescript-eslint/parser": [".ts"], 13 | }, 14 | "import/resolver": { 15 | typescript: {}, 16 | }, 17 | }, 18 | }; 19 | 20 | module.exports = config; 21 | -------------------------------------------------------------------------------- /examples/nested-configs/src/foo.ts: -------------------------------------------------------------------------------- 1 | export const bar = "bar"; 2 | -------------------------------------------------------------------------------- /examples/nested-configs/src/index.ts: -------------------------------------------------------------------------------- 1 | import { bar } from "./foo"; // error: import ./foo can be written as #src-overwritten/foo 2 | 3 | export const baz = bar + "baz"; 4 | -------------------------------------------------------------------------------- /examples/nested-configs/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "outDir": "./build", 8 | "paths": { 9 | "#src-overwritten/*": ["./*"] 10 | }, 11 | "resolveJsonModule": true, 12 | "strictNullChecks": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/nested-configs/test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ["plugin:@limegrass/import-alias/recommended"], 7 | parser: "@typescript-eslint/parser", 8 | plugins: ["@typescript-eslint", "@limegrass/import-alias"], 9 | root: true, 10 | rules: { 11 | "@limegrass/import-alias/import-alias": ["warn"], 12 | }, 13 | settings: { 14 | "import/parsers": { 15 | "@typescript-eslint/parser": [".ts"], 16 | }, 17 | "import/resolver": { 18 | typescript: {}, 19 | }, 20 | }, 21 | }; 22 | 23 | module.exports = config; 24 | -------------------------------------------------------------------------------- /examples/nested-configs/test/foo.ts: -------------------------------------------------------------------------------- 1 | export const bar = "bar"; 2 | -------------------------------------------------------------------------------- /examples/nested-configs/test/index.ts: -------------------------------------------------------------------------------- 1 | import { bar } from "./foo"; // warn: import ./foo can be written as #root/test/foo 2 | 3 | export const baz = bar + "baz"; 4 | -------------------------------------------------------------------------------- /examples/nested-configs/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nested-configs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/*", "public/*"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "outDir": "./build", 9 | "paths": { 10 | "#src/*": ["src/*"], 11 | "#root/*": ["*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/specific-tsconfig/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | parser: "@typescript-eslint/parser", 7 | plugins: ["@typescript-eslint", "@limegrass/import-alias"], 8 | root: true, 9 | rules: { 10 | "@limegrass/import-alias/import-alias": [ 11 | "error", 12 | { 13 | // `"./configs/tsconfig.preferred.json"` also works 14 | aliasConfigPath: `${__dirname}/configs/tsconfig.preferred.json`, 15 | }, 16 | ], 17 | }, 18 | settings: { 19 | "import/parsers": { 20 | "@typescript-eslint/parser": [".ts"], 21 | }, 22 | "import/resolver": { 23 | typescript: {}, 24 | }, 25 | }, 26 | }; 27 | 28 | module.exports = config; 29 | -------------------------------------------------------------------------------- /examples/specific-tsconfig/configs/tsconfig.preferred.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/*", "public/*"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "baseUrl": "../", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "outDir": "./build", 9 | "paths": { 10 | "#from-preferred-config/*": ["src/*"] 11 | }, 12 | "resolveJsonModule": true, 13 | "strictNullChecks": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/specific-tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "build/index.js", 3 | "scripts": { 4 | "build": "tsc", 5 | "prepare": "npm run build" 6 | }, 7 | "devDependencies": { 8 | "@types/node": "^20", 9 | "@typescript-eslint/eslint-plugin": "^5", 10 | "@typescript-eslint/parser": "^5", 11 | "eslint": "^8", 12 | "eslint-import-resolver-typescript": "^3", 13 | "@limegrass/eslint-plugin-import-alias": "file:../../build", 14 | "typescript": "^5" 15 | }, 16 | "name": "@limegrass/eslint-plugin-import-alias.example.basic", 17 | "version": "1.0.0", 18 | "description": "Example usage of specifying a particular TSConfig with a path", 19 | "author": "limegrass", 20 | "license": "MIT" 21 | } 22 | -------------------------------------------------------------------------------- /examples/specific-tsconfig/src/foo.ts: -------------------------------------------------------------------------------- 1 | export const bar = "bar"; 2 | -------------------------------------------------------------------------------- /examples/specific-tsconfig/src/index.ts: -------------------------------------------------------------------------------- 1 | import { bar } from "./foo"; // error: import ./foo can be written as #from-preferred-config/foo 2 | 3 | export const baz = bar + "baz"; 4 | -------------------------------------------------------------------------------- /examples/specific-tsconfig/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/*", "public/*"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "outDir": "./build", 9 | "paths": { 10 | "#src/*": ["src/*"], 11 | "#root/*": ["*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "@jest/types"; 2 | 3 | const config: Config.InitialOptions = { 4 | roots: ["/tests"], 5 | testMatch: ["/tests/**/*.test.ts"], 6 | preset: "ts-jest", 7 | transform: { 8 | "^.+\\.(js|ts)$": [ 9 | "ts-jest", 10 | { 11 | isolatedModules: true, 12 | diagnostics: false, 13 | }, 14 | ], 15 | }, 16 | moduleNameMapper: { 17 | "#src/(.*)": "/src/$1", 18 | "#root/(.*)": "/$1", 19 | }, 20 | moduleFileExtensions: ["js", "ts"], 21 | }; 22 | 23 | export = config; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "build/index.js", 3 | "scripts": { 4 | "build": "tsc -p tsconfig.build.json", 5 | "clean": "rimraf build", 6 | "husky": "is-ci || husky install", 7 | "prepare": "npm run husky && npm run clean && npm run build", 8 | "test": "jest", 9 | "coverage": "jest --coverage" 10 | }, 11 | "files": [ 12 | "build" 13 | ], 14 | "imports": { 15 | "#src/*": "./build/*.js" 16 | }, 17 | "dependencies": { 18 | "fs-extra": "^10.0.1", 19 | "micromatch": "^4.0.0", 20 | "slash": "^3.0.0", 21 | "tsconfig-paths": "^4" 22 | }, 23 | "peerDependencies": { 24 | "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8.40.0 || ^9" 25 | }, 26 | "devDependencies": { 27 | "@babel/types": "^7.17.0", 28 | "@commitlint/cli": "^19.2.1", 29 | "@commitlint/config-conventional": "^19.1.0", 30 | "@eslint/compat": "^1", 31 | "@limegrass/eslint-plugin-import-alias": "file:./build", 32 | "@types/eslint": "^9", 33 | "@types/estree": "^1", 34 | "@types/fs-extra": "^9.0.13", 35 | "@types/jest": "^29.5.14", 36 | "@types/json-schema": "^7.0.11", 37 | "@types/micromatch": "^4.0.2", 38 | "@types/node": "^18", 39 | "@types/prettier": "^2.4.4", 40 | "@typescript-eslint/eslint-plugin": "^8", 41 | "@typescript-eslint/parser": "^8", 42 | "eslint": "^9", 43 | "eslint-config-prettier": "^9", 44 | "eslint-import-resolver-typescript": "^3", 45 | "eslint-plugin-import": "^2.31.0", 46 | "eslint-plugin-tsdoc": "^0.4.0", 47 | "husky": "^7.0.4", 48 | "is-ci": "^3.0.1", 49 | "jest": "^29.7.0", 50 | "jest-mock": "^29.7.0", 51 | "lint-staged": "^12.3.5", 52 | "prettier": "^3", 53 | "rimraf": "^6.0.1", 54 | "ts-jest": "^29.1.4", 55 | "ts-node": "^10.7.0", 56 | "typescript": "^4.6.2" 57 | }, 58 | "repository": { 59 | "url": "https://github.com/Limegrass/eslint-plugin-import-alias", 60 | "type": "git" 61 | }, 62 | "name": "@limegrass/eslint-plugin-import-alias", 63 | "version": "1.5.1", 64 | "description": "Rewrite imports to TSConfig aliases", 65 | "author": "Limegrass", 66 | "license": "MIT" 67 | } 68 | -------------------------------------------------------------------------------- /src/alias-config.ts: -------------------------------------------------------------------------------- 1 | import micromatch from "micromatch"; 2 | import { dirname, isAbsolute, join as joinPath } from "path"; 3 | import { 4 | ConfigLoaderResult, 5 | ConfigLoaderSuccessResult, 6 | loadConfig, 7 | } from "tsconfig-paths"; 8 | 9 | type AliasConfig = { 10 | alias: string; 11 | path: { 12 | /** Glob values using absolute paths */ 13 | absolute: string; 14 | }; 15 | }; 16 | 17 | function loadTsconfig( 18 | eslintConfigPath: string, 19 | aliasConfigPath: string | undefined, 20 | codeFilePath: string, 21 | ) { 22 | let config: ConfigLoaderResult; 23 | try { 24 | if (aliasConfigPath) { 25 | if (isAbsolute(aliasConfigPath)) { 26 | config = loadConfig(aliasConfigPath); 27 | } else { 28 | config = loadConfig( 29 | joinPath(eslintConfigPath, aliasConfigPath), 30 | ); 31 | } 32 | } else { 33 | config = loadConfig(dirname(codeFilePath)); 34 | } 35 | } catch (error) { 36 | if (error instanceof SyntaxError) { 37 | throw new Error( 38 | `SyntaxError in TSConfig/JSConfig: ${error.message}`, 39 | ); 40 | } 41 | throw error; 42 | } 43 | 44 | if (config.resultType !== "success") { 45 | throw new Error( 46 | `validate tsconfig or jsconfig provided and ensure compilerOptions.baseUrl is set: ${config.message}`, 47 | ); 48 | } 49 | 50 | return config; 51 | } 52 | 53 | function loadAliasConfigs( 54 | config: ConfigLoaderSuccessResult, 55 | projectBaseDir: string, 56 | ): AliasConfig[] { 57 | return Object.entries(config.paths).reduce( 58 | (configs, [aliasGlob, aliasPaths]) => { 59 | aliasPaths.forEach((aliasPath) => { 60 | const relativePathBase = micromatch.scan(aliasPath).base; 61 | configs.push({ 62 | alias: micromatch.scan(aliasGlob).base, 63 | path: { 64 | absolute: joinPath(projectBaseDir, relativePathBase), 65 | }, 66 | }); 67 | }); 68 | return configs; 69 | }, 70 | [] as AliasConfig[], 71 | ); 72 | } 73 | 74 | export { AliasConfig, loadAliasConfigs, loadTsconfig }; 75 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { importAliasRule } from "#src/rules/import-alias"; 2 | import { type ESLint } from "eslint"; 3 | 4 | export const rules = { 5 | "import-alias": importAliasRule, 6 | }; 7 | 8 | export const configs: Record = { 9 | recommended: { 10 | plugins: ["@limegrass/import-alias"], 11 | rules: { 12 | "@limegrass/import-alias/import-alias": [ 13 | "error", 14 | { 15 | isAllowBaseUrlResolvedImport: false, 16 | }, 17 | ], 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/rules/import-alias.ts: -------------------------------------------------------------------------------- 1 | import { AliasConfig, loadAliasConfigs, loadTsconfig } from "#src/alias-config"; 2 | import { AST, Rule } from "eslint"; 3 | import type { 4 | ExportAllDeclaration, 5 | ExportNamedDeclaration, 6 | ImportDeclaration, 7 | Program, 8 | } from "estree"; 9 | import { existsSync, readdirSync } from "fs-extra"; 10 | import type { JSONSchema4 } from "json-schema"; 11 | import { 12 | dirname, 13 | join as joinPath, 14 | relative, 15 | resolve, 16 | sep as pathSep, 17 | parse, 18 | } from "path"; 19 | import slash from "slash"; 20 | 21 | function isPermittedRelativeImport( 22 | importModuleName: string, 23 | relativeImportOverrides: RelativeImportConfig[], 24 | filepath: string, 25 | projectBaseDir: string, 26 | ) { 27 | const isRelativeImport = 28 | importModuleName.length > 0 && importModuleName[0] !== "."; 29 | if (isRelativeImport) { 30 | return false; 31 | } 32 | 33 | const importParts = importModuleName.split("/"); 34 | const relativeDepth = importParts.filter( 35 | (moduleNamePart) => moduleNamePart === "..", 36 | ).length; 37 | const relativeFilepath = relative(projectBaseDir, filepath); 38 | 39 | const configs = [...relativeImportOverrides]; 40 | configs.sort((a, b) => b.depth - a.depth); // rank depth descending 41 | for (const config of configs) { 42 | if ( 43 | ("path" in config && filepath.includes(resolve(config.path))) || 44 | ("pattern" in config && 45 | new RegExp(config.pattern).test(relativeFilepath)) 46 | ) { 47 | return relativeDepth <= config.depth; 48 | } 49 | } 50 | return false; 51 | } 52 | 53 | function getAliasSuggestion( 54 | importModuleName: string, 55 | aliasConfigs: AliasConfig[], 56 | absoluteDir: string, 57 | ) { 58 | const currentAliasConfig: AliasConfig | undefined = aliasConfigs.find( 59 | ({ alias }) => { 60 | const [baseModulePath] = importModuleName.split("/"); 61 | return baseModulePath === alias; 62 | }, 63 | ); 64 | 65 | let absoluteModulePath: string | undefined = undefined; 66 | if (importModuleName.trim().charAt(0) === ".") { 67 | absoluteModulePath = joinPath(absoluteDir, importModuleName); 68 | } else if (currentAliasConfig) { 69 | absoluteModulePath = importModuleName 70 | .replace(currentAliasConfig.alias, currentAliasConfig.path.absolute) 71 | .replace(/\//g, pathSep); 72 | } 73 | 74 | if (absoluteModulePath) { 75 | const bestAliasConfig = getBestAliasConfig( 76 | aliasConfigs, 77 | currentAliasConfig, 78 | absoluteModulePath, 79 | ); 80 | 81 | if (bestAliasConfig && bestAliasConfig !== currentAliasConfig) { 82 | return slash( 83 | absoluteModulePath.replace( 84 | bestAliasConfig.path.absolute, 85 | bestAliasConfig.alias, 86 | ), 87 | ); 88 | } 89 | } 90 | } 91 | 92 | function getBestAliasConfig( 93 | aliasConfigs: AliasConfig[], 94 | currentAlias: AliasConfig | undefined, 95 | absoluteModulePath: string, 96 | ) { 97 | const importPathParts = absoluteModulePath.split(pathSep); 98 | return aliasConfigs.reduce((currentBest, potentialAlias) => { 99 | const aliasPathParts = potentialAlias.path.absolute.split(pathSep); 100 | const isValidAlias = aliasPathParts.reduce( 101 | (isValid, aliasPathPart, index) => { 102 | return isValid && importPathParts[index] === aliasPathPart; 103 | }, 104 | true, 105 | ); 106 | const isMoreSpecificAlias = 107 | !currentBest || 108 | potentialAlias.path.absolute.length > 109 | currentBest.path.absolute.length; 110 | return isValidAlias && isMoreSpecificAlias 111 | ? potentialAlias 112 | : currentBest; 113 | }, currentAlias); 114 | } 115 | 116 | interface RelativePathConfig { 117 | /** 118 | * The starting path from which a relative depth is accepted. 119 | * 120 | * @example 121 | * With a configuration like `{ path: "src/foo", depth: 0 }` 122 | * 1. Relative paths can be used in `./src/foo`. 123 | * 2. Relative paths can be used in `./src/foo/bar`. 124 | * 3. Relative paths can NOT be used in `./src`. 125 | * 126 | * @example 127 | * With a configuration like `{ path: "src", depth: 0 }` 128 | * 1. Relative paths can be used in `./src/foo`. 129 | * 2. Relative paths can be used in `./src/bar/baz`. 130 | * 3. Relative paths can be used in `./src`. 131 | */ 132 | path: string; 133 | /** 134 | * A positive number which represents the relative depth 135 | * that is acceptable for the associated path. 136 | * 137 | * @example 138 | * In `./src/foo` with `path: "src"` 139 | * 1. `import "./bar"` for `./src/bar` when `depth` \>= `0`. 140 | * 2. `import "./bar/baz"` when `depth` \>= `0`. 141 | * 3. `import "../bar"` when `depth` \>= `1`. 142 | */ 143 | depth: number; 144 | } 145 | 146 | interface RelativeGlobConfig { 147 | /** 148 | * A regex string pattern that is used to match the file path. 149 | * 150 | * @example 151 | * With a configuration like `{ pattern: "index.ts", depth: 0 }` 152 | * 1. Relative paths can be used in files such as `./src/index.ts`. 153 | * 1. Relative paths can be used any file in the folder `./src/index.ts/*`. 154 | * 2. Relative paths can NOT be used in files such as `./src/foo.ts`. 155 | * 156 | * @example 157 | * With a configuration like `{ pattern: "index\\.(ts|js)$" depth: 0 }` 158 | * 1. Relative paths can be used in any file that ends with `index.js` or `index.ts`. 159 | * 1. Relative paths can be NOT in the folder `./src/index.ts/*`. 160 | * 2. Relative paths can be NOT used in `./src/foo.ts`. 161 | */ 162 | pattern: string; 163 | /** 164 | * A positive number which represents the relative depth 165 | * that is acceptable for the associated path. 166 | * 167 | * @example 168 | * In `./src/foo.ts` with `pattern: "foo.ts$"` 169 | * 1. `import "./bar"` for `./src/bar` when `depth` \>= `0`. 170 | * 2. `import "./bar/baz"` when `depth` \>= `0`. 171 | * 3. `import "../bar"` when `depth` \>= `1`. 172 | */ 173 | depth: number; 174 | } 175 | 176 | type RelativeImportConfig = RelativePathConfig | RelativeGlobConfig; 177 | 178 | export type ImportAliasOptions = { 179 | aliasConfigPath?: string; 180 | // TODO: A fuller solution might need a property for the position, but not sure if needed 181 | aliasImportFunctions: string[]; 182 | /** An array defining which paths can be allowed to used relative imports within it to defined depths. */ 183 | relativeImportOverrides?: RelativeImportConfig[]; 184 | /** 185 | * A boolean which determines whether you would like to allow absolute path module resolution 186 | * through TSConfig's baseUrl alone. When set to false, absolute imports that does not use an 187 | * associated path assignment will be considered invalid by this rule. Defaults to `true` 188 | */ 189 | isAllowBaseUrlResolvedImport: boolean; 190 | }; 191 | 192 | /** 193 | * This should match the type definition of ImportAliasOptions 194 | */ 195 | const schemaProperties: Record = { 196 | aliasConfigPath: { 197 | description: "Alternative path to look for a TSConfig/JSConfig", 198 | type: "string", 199 | }, 200 | aliasImportFunctions: { 201 | type: "array", 202 | description: 203 | "Function names which supports aliases as its first function." + 204 | " Examples are `require` and `mock` for `jest.mock`." + 205 | " Please submit an issue if position parameters needs support", 206 | items: { 207 | type: "string", 208 | }, 209 | default: ["require", "mock"], 210 | }, 211 | relativeImportOverrides: { 212 | type: "array", 213 | default: [], 214 | items: { 215 | anyOf: [ 216 | { 217 | type: "object", 218 | properties: { 219 | path: { 220 | type: "string", 221 | description: 222 | "The starting path from which a relative depth is accepted." + 223 | " Required if `pattern` is not provided", 224 | }, 225 | depth: { 226 | type: "number", 227 | description: 228 | "A positive number which represents the" + 229 | " relative depth that is acceptable for the associated path.", 230 | }, 231 | }, 232 | }, 233 | 234 | { 235 | type: "object", 236 | properties: { 237 | pattern: { 238 | type: "string", 239 | description: 240 | "The pattern to match against the filename for from" + 241 | " which a relative depth is accepted." + 242 | " Required if `path` is not provided", 243 | }, 244 | depth: { 245 | type: "number", 246 | description: 247 | "A positive number which represents the" + 248 | " relative depth that is acceptable for the associated path.", 249 | }, 250 | }, 251 | }, 252 | ], 253 | }, 254 | }, 255 | isAllowBaseUrlResolvedImport: { 256 | type: "boolean", 257 | description: [ 258 | "A boolean which determines whether you would like to allow absolute path module resolution", 259 | "through TSConfig's baseUrl alone. When set to false, absolute imports that does not use an", 260 | "associated path assignment will be considered invalid by this rule. Defaults to `true`", 261 | ].join(" "), 262 | default: true, // default true for backwards compatibility 263 | }, 264 | }; 265 | 266 | const importAliasRule: Rule.RuleModule = { 267 | meta: { 268 | docs: { 269 | description: 270 | "Suggests shortest matching alias in TSConfig/JSConfig", 271 | category: "Suggestions", 272 | recommended: true, 273 | url: "https://github.com/limegrass/eslint-plugin-import-alias/blob/HEAD/docs/rules/import-alias.md", 274 | }, 275 | fixable: "code", 276 | schema: [ 277 | { 278 | type: "object", 279 | properties: schemaProperties, 280 | }, 281 | ], 282 | type: "suggestion", 283 | hasSuggestions: true, 284 | }, 285 | 286 | create: (context: Rule.RuleContext) => { 287 | const reportProgramError = (errorMessage: string) => { 288 | return { 289 | Program: (node: Program) => { 290 | context.report({ node, message: errorMessage }); 291 | }, 292 | }; 293 | }; 294 | 295 | /** 296 | * cwd seems to resolve to the path where the .eslintrc.js being used is found. 297 | * Thus, it is the appropriate place to append the aliasConfigPath from since that is 298 | * where the user would specify their aliasConfigPath relative to. 299 | * We can also otherwise resolve the tsconfig/jsconfig from the dirname(filepath), 300 | * which tsconfig-paths will attempt automatically for `tsconfig.json` and `jsconfig.json` 301 | */ 302 | const cwd = context.cwd; 303 | 304 | const filepath = resolve(context.filename); 305 | const absoluteDir = dirname(filepath); 306 | 307 | if (!existsSync(absoluteDir)) { 308 | return reportProgramError( 309 | "a filepath must be provided, try with --stdin-filename, " + 310 | "call eslint on a file, " + 311 | "or save your buffer as a file and restart eslint in your editor.", 312 | ); 313 | } 314 | 315 | const { 316 | aliasConfigPath, 317 | aliasImportFunctions = schemaProperties.aliasImportFunctions 318 | .default as string[], 319 | relativeImportOverrides = [], 320 | isAllowBaseUrlResolvedImport = true, 321 | }: ImportAliasOptions = context.options[0] || {}; // No idea what the other array values are 322 | 323 | let projectBaseDir: string; 324 | let aliasesResult: AliasConfig[]; 325 | try { 326 | const tsconfig = loadTsconfig(cwd, aliasConfigPath, filepath); 327 | const configDir = dirname(tsconfig.configFileAbsolutePath); 328 | projectBaseDir = joinPath(configDir, tsconfig.baseUrl ?? ""); 329 | aliasesResult = loadAliasConfigs(tsconfig, projectBaseDir); 330 | } catch (error) { 331 | if (error instanceof Error) { 332 | return reportProgramError(error.message); 333 | } 334 | throw error; 335 | } 336 | 337 | const getReportDescriptor = ( 338 | [moduleStart, moduleEnd]: [number, number], 339 | importModuleName: string, 340 | ) => { 341 | // preserve user quote style 342 | const quotelessRange: AST.Range = [moduleStart + 1, moduleEnd - 1]; 343 | 344 | if ( 345 | isPermittedRelativeImport( 346 | importModuleName, 347 | relativeImportOverrides, 348 | filepath, 349 | projectBaseDir, 350 | ) 351 | ) { 352 | return undefined; 353 | } 354 | 355 | const aliasSuggestion = getAliasSuggestion( 356 | importModuleName, 357 | aliasesResult, 358 | absoluteDir, 359 | ); 360 | 361 | if (aliasSuggestion) { 362 | return { 363 | message: `import ${importModuleName} can be written as ${aliasSuggestion}`, 364 | fix: (fixer: Rule.RuleFixer) => { 365 | return fixer.replaceTextRange( 366 | quotelessRange, 367 | aliasSuggestion, 368 | ); 369 | }, 370 | }; 371 | } 372 | 373 | // if no alias found, but user did not want to allow baseUrl resolved absolute imports 374 | // check if it would exist as a baseUrl absolute import. If so, make suggestions. 375 | if (!isAllowBaseUrlResolvedImport) { 376 | const joinedModulePath = joinPath( 377 | projectBaseDir, 378 | importModuleName, 379 | ); 380 | let moduleExists = false; 381 | try { 382 | const dirContents = readdirSync(dirname(joinedModulePath)); 383 | const moduleName = importModuleName.split("/").pop(); 384 | moduleExists = dirContents.some((filename) => { 385 | return parse(filename).name === moduleName; 386 | }); 387 | } catch { 388 | // module does not exist, do nothing as it is probably a dependency import 389 | } 390 | 391 | if (moduleExists) { 392 | const aliasConfig = getBestAliasConfig( 393 | aliasesResult, 394 | undefined, 395 | joinedModulePath, 396 | ); 397 | 398 | if (aliasConfig) { 399 | const suggestedPathImport = slash( 400 | joinedModulePath.replace( 401 | aliasConfig.path.absolute, 402 | aliasConfig.alias, 403 | ), 404 | ); 405 | if (suggestedPathImport !== importModuleName) { 406 | return { 407 | message: `import ${importModuleName} can be written as ${suggestedPathImport}`, 408 | fix: (fixer: Rule.RuleFixer) => { 409 | return fixer.replaceTextRange( 410 | quotelessRange, 411 | suggestedPathImport, 412 | ); 413 | }, 414 | }; 415 | } 416 | } else { 417 | return { 418 | message: `import ${importModuleName} is resolved from the TSConfig base URL without a path alias`, 419 | }; 420 | } 421 | } 422 | } 423 | 424 | return undefined; 425 | }; 426 | 427 | const aliasModuleDeclarations = ( 428 | node: 429 | | ImportDeclaration 430 | | ExportAllDeclaration 431 | | ExportNamedDeclaration, 432 | ) => { 433 | if (node.source?.range && typeof node.source.value === "string") { 434 | const suggestion = getReportDescriptor( 435 | node.source.range, 436 | node.source.value, 437 | ); 438 | 439 | if (suggestion) { 440 | context.report({ node, ...suggestion }); 441 | } 442 | } 443 | }; 444 | 445 | return { 446 | CallExpression: (node) => { 447 | const identifierNode = 448 | node.callee.type === "MemberExpression" 449 | ? node.callee.property 450 | : node.callee; 451 | 452 | if ( 453 | identifierNode.type === "Identifier" && 454 | aliasImportFunctions.includes(identifierNode.name) && 455 | node.arguments.length 456 | ) { 457 | const [importNameNode] = node.arguments; 458 | if ( 459 | importNameNode.range && 460 | "value" in importNameNode && 461 | typeof importNameNode.value === "string" 462 | ) { 463 | const suggestion = getReportDescriptor( 464 | importNameNode.range, 465 | importNameNode.value, 466 | ); 467 | 468 | if (suggestion) { 469 | context.report({ node, ...suggestion }); 470 | } 471 | } 472 | } 473 | }, 474 | ExportAllDeclaration: aliasModuleDeclarations, 475 | ExportNamedDeclaration: aliasModuleDeclarations, 476 | ImportDeclaration: aliasModuleDeclarations, 477 | }; 478 | }, 479 | }; 480 | 481 | export { importAliasRule }; 482 | -------------------------------------------------------------------------------- /tests/rules/import-alias.config.ts: -------------------------------------------------------------------------------- 1 | import type path from "path"; 2 | 3 | export const IMPORTED_MODULE_NAME = "imported-module"; 4 | export const UNKNOWN_MODULE_PATH = "unknown-module"; 5 | 6 | export function formatCode(format: string, importModuleName: string) { 7 | return format.replace("{import_module_name}", importModuleName); 8 | } 9 | 10 | export const CUSTOM_CALL_EXPRESSION_FUNCTION = "custom" as const; 11 | 12 | /** @see {@link formatCode} */ 13 | export const FORMAT_STRING = { 14 | ExportAllDecalaration: `export * from "{import_module_name}";`, 15 | ExportNamedDecalaration: `export { Foo } from "{import_module_name}";`, 16 | ImportDeclaration: `import { Foo } from "{import_module_name}";`, 17 | RequireCallExpression: `require("{import_module_name}");`, 18 | JestMockCallExpression: `jest.mock("{import_module_name}");`, 19 | CustomCallExpression: `${CUSTOM_CALL_EXPRESSION_FUNCTION}("{import_module_name}");`, 20 | } as const; 21 | 22 | export function mockReaddir(filepath: string) { 23 | if (filepath.includes(UNKNOWN_MODULE_PATH)) { 24 | return undefined; 25 | } 26 | 27 | return [`${IMPORTED_MODULE_NAME}.ts`]; 28 | } 29 | 30 | export function getMockAliasConfig( 31 | pathModule: typeof path, 32 | platform: "win32" | "posix", 33 | projectDir: string, 34 | ) { 35 | return [ 36 | { 37 | alias: "#src", 38 | path: { 39 | absolute: pathModule[platform].join(projectDir, "src"), 40 | }, 41 | }, 42 | { 43 | alias: "#src-alt-alias", 44 | path: { 45 | absolute: pathModule[platform].join(projectDir, "src"), 46 | }, 47 | }, 48 | { 49 | alias: "#sub-directory", 50 | path: { 51 | absolute: pathModule[platform].join( 52 | projectDir, 53 | "src", 54 | "sub-directory", 55 | ), 56 | }, 57 | }, 58 | { 59 | alias: "same-as-base-url-path", 60 | path: { 61 | absolute: pathModule[platform].join( 62 | projectDir, 63 | "same-as-base-url-path", 64 | ), 65 | }, 66 | }, 67 | ]; 68 | } 69 | -------------------------------------------------------------------------------- /tests/rules/import-alias.invalid-cases.ts: -------------------------------------------------------------------------------- 1 | import { ImportAliasOptions } from "#src/rules/import-alias"; 2 | import { RuleTester } from "eslint"; 3 | import { 4 | formatCode, 5 | FORMAT_STRING, 6 | IMPORTED_MODULE_NAME, 7 | } from "#root/tests/rules/import-alias.config"; 8 | 9 | type InvalidTestCaseParams = { 10 | description: string; 11 | sourceFilePath: string; 12 | import: { 13 | input: string; 14 | output?: string; 15 | }; 16 | options?: [Partial]; 17 | only?: true; 18 | }; 19 | 20 | /** These configurations are based on the test setup in the import-alias.config test file */ 21 | export function getInvalidTestCaseParams( 22 | fileSystemPath: string, 23 | optionsOverrides: Partial, 24 | ): InvalidTestCaseParams[] { 25 | const baseParams: InvalidTestCaseParams[] = [ 26 | { 27 | description: "unaliased sibling import from aliased path", 28 | sourceFilePath: "src/sub-directory/code.ts", 29 | import: { 30 | input: `./${IMPORTED_MODULE_NAME}`, 31 | output: `#sub-directory/${IMPORTED_MODULE_NAME}`, 32 | }, 33 | }, 34 | 35 | { 36 | description: "unaliased parent import from aliased path", 37 | sourceFilePath: "src/sub-directory/code.ts", 38 | import: { 39 | input: `../${IMPORTED_MODULE_NAME}`, 40 | output: `#src/${IMPORTED_MODULE_NAME}`, 41 | }, 42 | }, 43 | 44 | { 45 | description: "aliased but has more specific import", 46 | sourceFilePath: "src/code.ts", 47 | import: { 48 | input: `#src/sub-directory/${IMPORTED_MODULE_NAME}`, 49 | output: `#sub-directory/${IMPORTED_MODULE_NAME}`, 50 | }, 51 | }, 52 | 53 | { 54 | description: "unaliased sub-directory import", 55 | sourceFilePath: "src/code.ts", 56 | import: { 57 | input: `./sub-directory/${IMPORTED_MODULE_NAME}`, 58 | output: `#sub-directory/${IMPORTED_MODULE_NAME}`, 59 | }, 60 | }, 61 | 62 | { 63 | description: "global relative override insufficient depth", 64 | sourceFilePath: "src/sub-directory/code.ts", 65 | import: { 66 | input: `../${IMPORTED_MODULE_NAME}`, 67 | output: `#src/${IMPORTED_MODULE_NAME}`, 68 | }, 69 | options: [ 70 | { 71 | relativeImportOverrides: [ 72 | { 73 | path: ".", 74 | depth: 0, 75 | }, 76 | ], 77 | }, 78 | ], 79 | }, 80 | 81 | { 82 | description: "path relative override insufficient depth", 83 | sourceFilePath: "src/sub-directory/code.ts", 84 | import: { 85 | input: `../${IMPORTED_MODULE_NAME}`, 86 | output: `#src/${IMPORTED_MODULE_NAME}`, 87 | }, 88 | options: [ 89 | { 90 | relativeImportOverrides: [ 91 | { 92 | path: "src", 93 | depth: 0, 94 | }, 95 | ], 96 | }, 97 | ], 98 | }, 99 | 100 | { 101 | description: "pattern relative override insufficient depth", 102 | sourceFilePath: "src/sub-directory/code.ts", 103 | import: { 104 | input: `../${IMPORTED_MODULE_NAME}`, 105 | output: `#src/${IMPORTED_MODULE_NAME}`, 106 | }, 107 | options: [ 108 | { 109 | relativeImportOverrides: [ 110 | { 111 | pattern: "code.ts$", 112 | depth: 0, 113 | }, 114 | ], 115 | }, 116 | ], 117 | }, 118 | 119 | { 120 | description: "source code file not in configured override path", 121 | sourceFilePath: "src/sub-directory/code.ts", 122 | import: { 123 | input: `../${IMPORTED_MODULE_NAME}`, 124 | output: `#src/${IMPORTED_MODULE_NAME}`, 125 | }, 126 | options: [ 127 | { 128 | relativeImportOverrides: [ 129 | { 130 | path: "unknown", 131 | depth: 1, 132 | }, 133 | ], 134 | }, 135 | ], 136 | }, 137 | 138 | { 139 | description: "source code file not in configured override pattern", 140 | sourceFilePath: "src/sub-directory/code.ts", 141 | import: { 142 | input: `../${IMPORTED_MODULE_NAME}`, 143 | output: `#src/${IMPORTED_MODULE_NAME}`, 144 | }, 145 | options: [ 146 | { 147 | relativeImportOverrides: [ 148 | { 149 | pattern: "unknown$", 150 | depth: 1, 151 | }, 152 | ], 153 | }, 154 | ], 155 | }, 156 | 157 | { 158 | description: "user filesystem is not used in match", 159 | sourceFilePath: "src/sub-directory/code.ts", 160 | import: { 161 | input: `../${IMPORTED_MODULE_NAME}`, 162 | output: `#src/${IMPORTED_MODULE_NAME}`, 163 | }, 164 | options: [ 165 | { 166 | relativeImportOverrides: [ 167 | { 168 | pattern: fileSystemPath, 169 | depth: 1, 170 | }, 171 | ], 172 | }, 173 | ], 174 | }, 175 | 176 | { 177 | description: "baseUrl resolved module import when not allowed", 178 | sourceFilePath: "src/code.ts", 179 | import: { 180 | input: `src/sub-directory/${IMPORTED_MODULE_NAME}`, 181 | output: `#sub-directory/${IMPORTED_MODULE_NAME}`, 182 | }, 183 | options: [ 184 | { 185 | isAllowBaseUrlResolvedImport: false, 186 | }, 187 | ], 188 | }, 189 | 190 | { 191 | description: 192 | "baseUrl resolved module import when not allowed for existing file", 193 | sourceFilePath: "src/code.ts", 194 | import: { 195 | input: `known/${IMPORTED_MODULE_NAME}`, 196 | // output: NONE - user must add entry to TSConfig 197 | }, 198 | options: [ 199 | { 200 | isAllowBaseUrlResolvedImport: false, 201 | }, 202 | ], 203 | }, 204 | 205 | { 206 | description: 207 | "isAllowBaseUrlResolvedImport is not hampered by relativeImportOverrides", 208 | sourceFilePath: "src/code.ts", 209 | import: { 210 | input: `src/${IMPORTED_MODULE_NAME}`, 211 | output: `#src/${IMPORTED_MODULE_NAME}`, 212 | }, 213 | options: [ 214 | { 215 | isAllowBaseUrlResolvedImport: false, 216 | relativeImportOverrides: [ 217 | { 218 | path: ".", 219 | depth: 0, 220 | }, 221 | ], 222 | }, 223 | ], 224 | }, 225 | ]; 226 | 227 | return baseParams.map((params) => ({ 228 | ...params, 229 | options: [ 230 | { 231 | ...((params.options && params.options[0]) || {}), 232 | ...optionsOverrides, 233 | }, 234 | ], 235 | })); 236 | } 237 | 238 | export function generateInvalidTestCase( 239 | testCaseKind: keyof typeof FORMAT_STRING, 240 | params: InvalidTestCaseParams, 241 | ): RuleTester.InvalidTestCase { 242 | const inputCode = formatCode( 243 | FORMAT_STRING[testCaseKind], 244 | params.import.input, 245 | ); 246 | const outputCode = formatCode( 247 | FORMAT_STRING[testCaseKind], 248 | // RuleTester appears to default to the input string 249 | // only if output is explicitly not set (and NOT undefined). 250 | // This mimicks that behavior. 251 | params.import.output ?? params.import.input, 252 | ); 253 | 254 | const testCase: RuleTester.InvalidTestCase = { 255 | code: inputCode, 256 | errors: 1, 257 | filename: params.sourceFilePath, 258 | name: `${params.description} [${inputCode}]`, 259 | options: params.options, 260 | output: outputCode !== inputCode ? outputCode : null, 261 | }; 262 | 263 | if (params.only) { 264 | testCase.only = params.only; 265 | } 266 | 267 | return testCase; 268 | } 269 | -------------------------------------------------------------------------------- /tests/rules/import-alias.test.ts: -------------------------------------------------------------------------------- 1 | import { loadAliasConfigs, loadTsconfig } from "#src/alias-config"; 2 | import { rules } from "#src/index"; 3 | import { ImportAliasOptions } from "#src/rules/import-alias"; 4 | import { RuleTester } from "eslint"; 5 | import { existsSync, readdirSync } from "fs-extra"; 6 | import { mocked } from "jest-mock"; 7 | import { 8 | getMockAliasConfig, 9 | mockReaddir, 10 | CUSTOM_CALL_EXPRESSION_FUNCTION, 11 | } from "#root/tests/rules/import-alias.config"; 12 | import { 13 | generateValidTestCase, 14 | getValidTestCaseParams, 15 | } from "#root/tests/rules/import-alias.valid-cases"; 16 | import { 17 | generateInvalidTestCase, 18 | getInvalidTestCaseParams, 19 | } from "#root/tests/rules/import-alias.invalid-cases"; 20 | import { ConfigLoaderSuccessResult } from "tsconfig-paths"; 21 | 22 | jest.mock("#src/alias-config"); 23 | jest.mock("fs-extra"); 24 | 25 | const mockLoadAliasConfig = mocked(loadAliasConfigs); 26 | const mockLoadTsconfig = mocked(loadTsconfig); 27 | const mockExistsSync = mocked(existsSync); 28 | const mockReaddirSync = mocked(readdirSync); 29 | 30 | // eslint-disable-next-line @typescript-eslint/no-require-imports 31 | import path = require("path"); // object import required for overwriting 32 | jest.mock("path", () => { 33 | // path must be redefined as object so we can overwrite it 34 | const original = jest.requireActual("path"); 35 | return { 36 | ...original, 37 | posix: { 38 | ...original.posix, 39 | // required when running the tests on windows 40 | join: (...args: string[]) => { 41 | return original.posix.join( 42 | ...args.map((s: string) => s.replace(/\\/g, "/")), 43 | ); 44 | }, 45 | }, 46 | }; 47 | }); 48 | 49 | const ruleTester = new RuleTester({ 50 | languageOptions: { 51 | parserOptions: { ecmaVersion: 2015, sourceType: "module" }, 52 | }, 53 | }); 54 | 55 | const cwd = process.cwd(); 56 | 57 | const projectDirPart = cwd; 58 | 59 | const NO_OPTION_OVERRIDES: Partial = {}; 60 | function runTests(platform: "win32" | "posix") { 61 | beforeAll(() => { 62 | const projectDir = cwd; 63 | 64 | Object.assign(path, { 65 | ...path[platform], 66 | cwd: projectDir, 67 | }); 68 | 69 | mockReaddirSync.mockImplementation(mockReaddir as typeof readdirSync); 70 | 71 | mockLoadTsconfig.mockReturnValue({ 72 | baseUrl: ".", 73 | configFileAbsolutePath: path[platform].join( 74 | projectDir, 75 | "tsconfig.json", 76 | ), 77 | } as Partial as ConfigLoaderSuccessResult); 78 | 79 | const mockConfigs = getMockAliasConfig(path, platform, projectDir); 80 | mockLoadAliasConfig.mockReturnValue(mockConfigs); 81 | 82 | mockExistsSync.mockReturnValue(true); 83 | }); 84 | 85 | ruleTester.run("ExportAllDeclaration", rules["import-alias"], { 86 | valid: [ 87 | ...getValidTestCaseParams(NO_OPTION_OVERRIDES).map((params) => 88 | generateValidTestCase("ExportAllDecalaration", params), 89 | ), 90 | { 91 | name: "source-less default export [export default TestFn = () => {}]", 92 | code: `export default TestFn = () => {}`, 93 | filename: "src/test.ts", 94 | }, 95 | ], 96 | invalid: [ 97 | ...getInvalidTestCaseParams( 98 | projectDirPart, 99 | NO_OPTION_OVERRIDES, 100 | ).map((params) => 101 | generateInvalidTestCase("ExportAllDecalaration", params), 102 | ), 103 | ], 104 | }); 105 | 106 | ruleTester.run("ExportNamedDeclaration", rules["import-alias"], { 107 | valid: [ 108 | ...getValidTestCaseParams(NO_OPTION_OVERRIDES).map((params) => 109 | generateValidTestCase("ExportNamedDecalaration", params), 110 | ), 111 | { 112 | name: `source-less named export inline [export const TestFn = () => {}]`, 113 | code: `export const TestFn = () => {}`, 114 | filename: "src/code.ts", 115 | }, 116 | { 117 | name: `source-less named default export [const TestFn = () => {}; export { TestFn };]`, 118 | code: `const TestFn = () => {}; export { TestFn };`, 119 | filename: "src/code.ts", 120 | }, 121 | ], 122 | invalid: [ 123 | ...getInvalidTestCaseParams( 124 | projectDirPart, 125 | NO_OPTION_OVERRIDES, 126 | ).map((params) => 127 | generateInvalidTestCase("ExportNamedDecalaration", params), 128 | ), 129 | ], 130 | }); 131 | 132 | ruleTester.run("ImportDeclaration", rules["import-alias"], { 133 | valid: [ 134 | ...getValidTestCaseParams(NO_OPTION_OVERRIDES).map((params) => 135 | generateValidTestCase("ImportDeclaration", params), 136 | ), 137 | ], 138 | invalid: [ 139 | ...getInvalidTestCaseParams( 140 | projectDirPart, 141 | NO_OPTION_OVERRIDES, 142 | ).map((params) => 143 | generateInvalidTestCase("ImportDeclaration", params), 144 | ), 145 | ], 146 | }); 147 | 148 | describe("CallExpression", () => { 149 | ruleTester.run("`require` support by default", rules["import-alias"], { 150 | valid: [ 151 | ...getValidTestCaseParams(NO_OPTION_OVERRIDES).map((params) => 152 | generateValidTestCase("RequireCallExpression", params), 153 | ), 154 | ], 155 | invalid: [ 156 | ...getInvalidTestCaseParams( 157 | projectDirPart, 158 | NO_OPTION_OVERRIDES, 159 | ).map((params) => 160 | generateInvalidTestCase("RequireCallExpression", params), 161 | ), 162 | ], 163 | }); 164 | 165 | ruleTester.run( 166 | "`jest.mock` support by default", 167 | rules["import-alias"], 168 | { 169 | valid: [ 170 | ...getValidTestCaseParams(NO_OPTION_OVERRIDES).map( 171 | (params) => 172 | generateValidTestCase( 173 | "JestMockCallExpression", 174 | params, 175 | ), 176 | ), 177 | ], 178 | invalid: [ 179 | ...getInvalidTestCaseParams( 180 | projectDirPart, 181 | NO_OPTION_OVERRIDES, 182 | ).map((params) => 183 | generateInvalidTestCase( 184 | "JestMockCallExpression", 185 | params, 186 | ), 187 | ), 188 | ], 189 | }, 190 | ); 191 | 192 | const customCallExpressionOptionOverrides = { 193 | aliasImportFunctions: [CUSTOM_CALL_EXPRESSION_FUNCTION], 194 | }; 195 | ruleTester.run("custom CallExpression support", rules["import-alias"], { 196 | valid: [ 197 | ...getValidTestCaseParams( 198 | customCallExpressionOptionOverrides, 199 | ).map((params) => 200 | generateValidTestCase("CustomCallExpression", params), 201 | ), 202 | ], 203 | invalid: [ 204 | ...getInvalidTestCaseParams( 205 | projectDirPart, 206 | customCallExpressionOptionOverrides, 207 | ).map((params) => 208 | generateInvalidTestCase("CustomCallExpression", params), 209 | ), 210 | ], 211 | }); 212 | }); 213 | } 214 | 215 | describe("Unix", () => { 216 | runTests("posix"); 217 | }); 218 | 219 | describe("Windows", () => { 220 | runTests("win32"); 221 | }); 222 | -------------------------------------------------------------------------------- /tests/rules/import-alias.valid-cases.ts: -------------------------------------------------------------------------------- 1 | import { ImportAliasOptions } from "#src/rules/import-alias"; 2 | import { RuleTester } from "eslint"; 3 | import { 4 | formatCode, 5 | FORMAT_STRING, 6 | IMPORTED_MODULE_NAME, 7 | UNKNOWN_MODULE_PATH, 8 | } from "#root/tests/rules/import-alias.config"; 9 | 10 | type ValidTestCaseParams = { 11 | description: string; 12 | sourceFilePath: string; 13 | import: { 14 | input: string; 15 | }; 16 | options?: [Partial]; 17 | only?: true; 18 | }; 19 | 20 | /** These configurations are based on the test setup in the import-alias.config test file */ 21 | export function getValidTestCaseParams( 22 | optionsOverrides: Partial, 23 | ): ValidTestCaseParams[] { 24 | const baseParams: ValidTestCaseParams[] = [ 25 | { 26 | description: "aliased source import", 27 | sourceFilePath: "src/code.ts", 28 | import: { 29 | input: `#src/${IMPORTED_MODULE_NAME}`, 30 | }, 31 | }, 32 | 33 | { 34 | description: "aliased sub-directory import", 35 | sourceFilePath: "src/code.ts", 36 | import: { 37 | input: `#sub-directory/${IMPORTED_MODULE_NAME}`, 38 | }, 39 | }, 40 | 41 | { 42 | description: "multiple aliases for one import path accepted", 43 | sourceFilePath: "src/code.ts", 44 | import: { 45 | input: `#src-alt-alias/${IMPORTED_MODULE_NAME}`, 46 | }, 47 | }, 48 | 49 | { 50 | description: "unaliased directory with partial name match", 51 | sourceFilePath: "src/code.ts", 52 | import: { 53 | input: `../src-unaliased/${IMPORTED_MODULE_NAME}`, 54 | }, 55 | }, 56 | 57 | { 58 | description: 59 | "depth 0 relative module path with override for all project files", 60 | sourceFilePath: "src/sub-directory/code.ts", 61 | import: { 62 | input: `./${IMPORTED_MODULE_NAME}`, 63 | }, 64 | options: [ 65 | { 66 | relativeImportOverrides: [ 67 | { 68 | path: ".", 69 | depth: 0, 70 | }, 71 | ], 72 | }, 73 | ], 74 | }, 75 | { 76 | description: 77 | "depth 0 relative module path with override for given path", 78 | sourceFilePath: "src/sub-directory/code.ts", 79 | import: { 80 | input: `./${IMPORTED_MODULE_NAME}`, 81 | }, 82 | options: [ 83 | { 84 | relativeImportOverrides: [ 85 | { 86 | path: "src", 87 | depth: 0, 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | { 94 | description: 95 | "depth 1 relative module path with override for all project files", 96 | sourceFilePath: "src/sub-directory/code.ts", 97 | import: { 98 | input: `../${IMPORTED_MODULE_NAME}`, 99 | }, 100 | options: [ 101 | { 102 | relativeImportOverrides: [ 103 | { 104 | path: ".", 105 | depth: 1, 106 | }, 107 | ], 108 | }, 109 | ], 110 | }, 111 | { 112 | description: 113 | "depth 1 relative module path with override for given path", 114 | sourceFilePath: "src/sub-directory/code.ts", 115 | import: { 116 | input: `../${IMPORTED_MODULE_NAME}`, 117 | }, 118 | options: [ 119 | { 120 | relativeImportOverrides: [ 121 | { 122 | path: "src", 123 | depth: 1, 124 | }, 125 | ], 126 | }, 127 | ], 128 | }, 129 | 130 | { 131 | description: 132 | "depth 0 relative module path with override for all project files", 133 | sourceFilePath: "src/code.ts", 134 | import: { 135 | input: `./${IMPORTED_MODULE_NAME}`, 136 | }, 137 | options: [ 138 | { 139 | relativeImportOverrides: [ 140 | { 141 | pattern: `.`, 142 | depth: 0, 143 | }, 144 | ], 145 | }, 146 | ], 147 | }, 148 | { 149 | description: 150 | "depth 0 relative module path with override for given pattern", 151 | sourceFilePath: "src/code.ts", 152 | import: { 153 | input: `./${IMPORTED_MODULE_NAME}`, 154 | }, 155 | options: [ 156 | { 157 | relativeImportOverrides: [ 158 | { 159 | pattern: `code.ts$`, 160 | depth: 0, 161 | }, 162 | ], 163 | }, 164 | ], 165 | }, 166 | { 167 | description: 168 | "depth 1 relative module path with override for all project files", 169 | sourceFilePath: "src/sub-directory/code.ts", 170 | import: { 171 | input: `../${IMPORTED_MODULE_NAME}`, 172 | }, 173 | options: [ 174 | { 175 | relativeImportOverrides: [ 176 | { 177 | pattern: `.`, 178 | depth: 1, 179 | }, 180 | ], 181 | }, 182 | ], 183 | }, 184 | { 185 | description: 186 | "depth 1 relative module path with override for given pattern", 187 | sourceFilePath: "src/sub-directory/code.ts", 188 | import: { 189 | input: `../${IMPORTED_MODULE_NAME}`, 190 | }, 191 | options: [ 192 | { 193 | relativeImportOverrides: [ 194 | { 195 | pattern: `code.ts$`, 196 | depth: 1, 197 | }, 198 | ], 199 | }, 200 | ], 201 | }, 202 | 203 | { 204 | description: "pattern and path override resolves matching path", 205 | sourceFilePath: "src/code.ts", 206 | import: { 207 | input: `./${IMPORTED_MODULE_NAME}`, 208 | }, 209 | options: [ 210 | { 211 | relativeImportOverrides: [ 212 | { 213 | path: "src", 214 | depth: 0, 215 | }, 216 | { 217 | pattern: "bad-pattern$", 218 | depth: 0, 219 | }, 220 | ], 221 | }, 222 | ], 223 | }, 224 | { 225 | description: "pattern and path override resolves matching pattern", 226 | sourceFilePath: "src/code.ts", 227 | import: { 228 | input: `./${IMPORTED_MODULE_NAME}`, 229 | }, 230 | options: [ 231 | { 232 | relativeImportOverrides: [ 233 | { 234 | path: "bad-path", 235 | depth: 0, 236 | }, 237 | { 238 | pattern: `code.ts$`, 239 | depth: 0, 240 | }, 241 | ], 242 | }, 243 | ], 244 | }, 245 | 246 | { 247 | description: 248 | "depth and path override resolves higher depth pattern", 249 | sourceFilePath: "src/sub-directory/code.ts", 250 | import: { 251 | input: `../${IMPORTED_MODULE_NAME}`, 252 | }, 253 | options: [ 254 | { 255 | relativeImportOverrides: [ 256 | { 257 | path: "src", 258 | depth: 0, 259 | }, 260 | { 261 | pattern: `code.ts$`, 262 | depth: 1, 263 | }, 264 | ], 265 | }, 266 | ], 267 | }, 268 | { 269 | description: "depth and path override resolves higher depth path", 270 | sourceFilePath: "src/sub-directory/code.ts", 271 | import: { 272 | input: `../${IMPORTED_MODULE_NAME}`, 273 | }, 274 | options: [ 275 | { 276 | relativeImportOverrides: [ 277 | { 278 | path: "src", 279 | depth: 1, 280 | }, 281 | { 282 | pattern: `code.ts$`, 283 | depth: 0, 284 | }, 285 | ], 286 | }, 287 | ], 288 | }, 289 | 290 | { 291 | description: "absolute import by baseUrl resolved import", 292 | sourceFilePath: "src/code.ts", 293 | import: { 294 | input: `src/${IMPORTED_MODULE_NAME}`, 295 | }, 296 | }, 297 | 298 | { 299 | description: "unknown import with !isAllowBaseUrlResolvedImport", 300 | sourceFilePath: "src/code.ts", 301 | import: { 302 | input: `${UNKNOWN_MODULE_PATH}`, 303 | }, 304 | options: [ 305 | { 306 | isAllowBaseUrlResolvedImport: false, 307 | }, 308 | ], 309 | }, 310 | 311 | { 312 | description: 313 | "alias with same value as baseUrl resolved import when !isAllowBaseUrlResolvedImport", 314 | sourceFilePath: "src/code.ts", 315 | import: { 316 | input: `same-as-base-url-path/${IMPORTED_MODULE_NAME}`, 317 | }, 318 | options: [ 319 | { 320 | isAllowBaseUrlResolvedImport: false, 321 | }, 322 | ], 323 | }, 324 | ]; 325 | 326 | return baseParams.map((params) => ({ 327 | ...params, 328 | options: [ 329 | { 330 | ...((params.options && params.options[0]) || {}), 331 | ...optionsOverrides, 332 | }, 333 | ], 334 | })); 335 | } 336 | 337 | export function generateValidTestCase( 338 | testCaseKind: keyof typeof FORMAT_STRING, 339 | params: ValidTestCaseParams, 340 | ): RuleTester.ValidTestCase { 341 | const code = formatCode(FORMAT_STRING[testCaseKind], params.import.input); 342 | 343 | const testCase: RuleTester.ValidTestCase = { 344 | code, 345 | name: `${params.description} [${code}]`, 346 | filename: params.sourceFilePath, 347 | options: params.options, 348 | }; 349 | 350 | if (params.only) { 351 | testCase.only = params.only; 352 | } 353 | 354 | return testCase; 355 | } 356 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["tests"], 3 | "extends": "./tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/*"], 3 | "include": ["src/**/*.ts", "tests/**/*.ts"], 4 | "compilerOptions": { 5 | "lib": ["es2015"], 6 | "baseUrl": ".", 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "outDir": "./build", 10 | "paths": { 11 | "#src/*": ["src/*"], 12 | "#root/*": ["*"] 13 | }, 14 | "resolveJsonModule": true, 15 | "strictNullChecks": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------