diff --git a/package.json b/package.json index aebbc3081..1cc0d8151 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "escope": "^3.6.0", "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", "eslint-import-resolver-node": "file:./resolvers/node", - "eslint-import-resolver-typescript": "^1.0.2 || ^1.1.1", + "eslint-import-resolver-typescript": "^1.0.2 || ^2.5.0", "eslint-import-resolver-webpack": "file:./resolvers/webpack", "eslint-import-test-order-redirect": "file:./tests/files/order-redirect", "eslint-module-utils": "file:./utils", diff --git a/src/rules/no-extraneous-dependencies.js b/src/rules/no-extraneous-dependencies.js index 25a91aef5..9858979ab 100644 --- a/src/rules/no-extraneous-dependencies.js +++ b/src/rules/no-extraneous-dependencies.js @@ -2,7 +2,10 @@ import path from 'path'; import fs from 'fs'; import readPkgUp from 'eslint-module-utils/readPkgUp'; import minimatch from 'minimatch'; -import resolve from 'eslint-module-utils/resolve'; +import resolve, { + requireResolver, + resolverReducer, +} from 'eslint-module-utils/resolve'; import moduleVisitor from 'eslint-module-utils/moduleVisitor'; import importType from '../core/importType'; import { getFilePackageName } from '../core/packagePath'; @@ -117,6 +120,27 @@ function optDepErrorMessage(packageName) { `not optionalDependencies.`; } +function resolveTypeOnly(modulePath, context) { + const sourceFile = context.getPhysicalFilename + ? context.getPhysicalFilename() + : context.getFilename(); + const configResolvers = context.settings['import/resolver']; + if (!configResolvers) return; + const resolvers = resolverReducer(configResolvers, new Map()); + for (const [name, config] of resolvers) { + switch (name) { + case 'typescript': + case 'eslint-import-resolver-typescript': { + const resolver = requireResolver(name, sourceFile); + const resolved = resolver.resolve(modulePath, sourceFile, config); + if (resolved.found) { + return resolved.path; + } + } + } + } +} + function getModuleOriginalName(name) { const [first, second] = name.split('/'); return first.startsWith('@') ? `${first}/${second}` : first; @@ -160,10 +184,7 @@ function checkDependencyDeclaration(deps, packageName, declarationStatus) { function reportIfMissing(context, deps, depsOptions, node, name) { // Do not report when importing types - if ( - node.importKind === 'type' || - node.importKind === 'typeof' - ) { + if (node.importKind === 'typeof') { return; } @@ -171,11 +192,17 @@ function reportIfMissing(context, deps, depsOptions, node, name) { return; } - const resolved = resolve(name, context); + const isTypeOnly = node.importKind === 'type'; + const resolved = (isTypeOnly ? resolveTypeOnly : resolve)(name, context); if (!resolved) { return; } - const importPackageName = getModuleOriginalName(name); - let declarationStatus = checkDependencyDeclaration(deps, importPackageName); + // If this is type-only, only check realPackageName, because if you + // e.g. import type { JSONSchema7Type } from 'json-schema', + // realPackageName could be @types/json-schema but importPackageName + // will be json-schema and that package could be untyped. + const importPackageName = !isTypeOnly && getModuleOriginalName(name); + let declarationStatus = + !isTypeOnly && checkDependencyDeclaration(deps, importPackageName); if ( declarationStatus.isInDeps || @@ -195,7 +222,8 @@ function reportIfMissing(context, deps, depsOptions, node, name) { if ( declarationStatus.isInDeps || - (depsOptions.allowDevDeps && declarationStatus.isInDevDeps) || + ((depsOptions.allowDevDeps || isTypeOnly) && + declarationStatus.isInDevDeps) || (depsOptions.allowPeerDeps && declarationStatus.isInPeerDeps) || (depsOptions.allowOptDeps && declarationStatus.isInOptDeps) || (depsOptions.allowBundledDeps && declarationStatus.isInBundledDeps) diff --git a/tests/files/node_modules/@types/a/index.d.ts b/tests/files/node_modules/@types/a/index.d.ts new file mode 100644 index 000000000..e69de29bb diff --git a/tests/files/node_modules/@types/a/package.json b/tests/files/node_modules/@types/a/package.json new file mode 100644 index 000000000..d85d65842 --- /dev/null +++ b/tests/files/node_modules/@types/a/package.json @@ -0,0 +1,3 @@ +{ + "name": "@types/a" +} diff --git a/tests/src/core/resolve.js b/tests/src/core/resolve.js index 360d4a2e7..2404c0f24 100644 --- a/tests/src/core/resolve.js +++ b/tests/src/core/resolve.js @@ -2,7 +2,12 @@ import { expect } from 'chai'; import eslintPkg from 'eslint/package.json'; import semver from 'semver'; -import resolve, { CASE_SENSITIVE_FS, fileExistsWithCaseSync } from 'eslint-module-utils/resolve'; +import resolve, { + CASE_SENSITIVE_FS, + fileExistsWithCaseSync, + requireResolver, + resolverReducer, +} from 'eslint-module-utils/resolve'; import * as path from 'path'; import * as fs from 'fs'; @@ -433,3 +438,39 @@ describe('resolve', function () { }); }); + +describe('resolverReducer', () => { + it('accepts a string', () => { + expect([...resolverReducer('node', new Map())]).to.deep.equal([ + ['node', null], + ]); + }); + + it('accepts an array', () => { + expect([ + ...resolverReducer(['node', 'typescript'], new Map()), + ]).to.deep.equal([ + ['node', null], + ['typescript', null], + ]); + }); + + it('accepts an object', () => { + expect([ + ...resolverReducer({ node: null, typescript: null }, new Map()), + ]).to.deep.equal([ + ['node', null], + ['typescript', null], + ]); + }); +}); + +describe('requireResolver', () => { + it('accepts a conventional resolver name', () => { + requireResolver('node'); + }); + + it('accepts a full package name', () => { + requireResolver('eslint-import-resolver-node'); + }); +}); diff --git a/tests/src/rules/no-extraneous-dependencies.js b/tests/src/rules/no-extraneous-dependencies.js index 131604ad9..ab850eda6 100644 --- a/tests/src/rules/no-extraneous-dependencies.js +++ b/tests/src/rules/no-extraneous-dependencies.js @@ -1,7 +1,9 @@ import { getTSParsers, test, testFilePath } from '../utils'; import typescriptConfig from '../../../config/typescript'; import path from 'path'; +import typescriptResolverPkg from 'eslint-import-resolver-typescript/package.json'; import fs from 'fs'; +import semver from 'semver'; import { RuleTester } from 'eslint'; import flatMap from 'array.prototype.flatmap'; @@ -378,38 +380,75 @@ ruleTester.run('no-extraneous-dependencies', rule, { ], }); -describe('TypeScript', () => { - getTSParsers() - // Type-only imports were added in TypeScript ESTree 2.23.0 - .filter((parser) => parser !== require.resolve('typescript-eslint-parser')) - .forEach((parser) => { - const parserConfig = { - parser, - settings: { - 'import/parsers': { [parser]: ['.ts'] }, - 'import/resolver': ['node', 'typescript'], - }, - }; +if (semver.satisfies(typescriptResolverPkg.version, '>=2.1.0')) { + describe('TypeScript', () => { + getTSParsers() + // Type-only imports were added in TypeScript ESTree 2.23.0 + .filter((parser) => parser !== require.resolve('typescript-eslint-parser')) + .forEach((parser) => { + const parserConfig = { + parser, + settings: { + 'import/parsers': { [parser]: ['.ts'] }, + 'import/resolver': ['node', 'typescript'], + }, + }; - ruleTester.run('no-extraneous-dependencies', rule, { - valid: [ - test(Object.assign({ - code: 'import type T from "a";', - options: [{ packageDir: packageDirWithTypescriptDevDependencies, devDependencies: false }], - }, parserConfig)), - ], - invalid: [ - test(Object.assign({ - code: 'import T from "a";', - options: [{ packageDir: packageDirWithTypescriptDevDependencies, devDependencies: false }], - errors: [{ - message: "'a' should be listed in the project's dependencies, not devDependencies.", - }], - }, parserConfig)), - ], + ruleTester.run('no-extraneous-dependencies', rule, { + valid: [], + invalid: [ + test(Object.assign({ + code: 'import T from "a";', + options: [{ packageDir: packageDirWithTypescriptDevDependencies, devDependencies: false }], + errors: [{ + message: "'a' should be listed in the project's dependencies, not devDependencies.", + }], + }, parserConfig)), + test(Object.assign({ + code: 'import type T from "a";', + options: [{ packageDir: packageDirWithTypescriptDevDependencies, devDependencies: false }], + errors: [{ + message: "'@types/a' should be listed in the project's dependencies. Run 'npm i -S @types/a' to add it", + }], + }, parserConfig)), + ], + }); }); - }); -}); + }); +} else { + describe('Legacy TypeScript resolver', () => { + getTSParsers() + // Type-only imports were added in TypeScript ESTree 2.23.0 + .filter((parser) => parser !== require.resolve('typescript-eslint-parser')) + .forEach((parser) => { + const parserConfig = { + parser, + settings: { + 'import/parsers': { [parser]: ['.ts'] }, + 'import/resolver': ['node', 'typescript'], + }, + }; + + ruleTester.run('no-extraneous-dependencies', rule, { + valid: [ + test(Object.assign({ + code: 'import type T from "a";', + options: [{ packageDir: packageDirWithTypescriptDevDependencies, devDependencies: false }], + }, parserConfig)), + ], + invalid: [ + test(Object.assign({ + code: 'import T from "a";', + options: [{ packageDir: packageDirWithTypescriptDevDependencies, devDependencies: false }], + errors: [{ + message: "'a' should be listed in the project's dependencies, not devDependencies.", + }], + }, parserConfig)), + ], + }); + }); + }); +} typescriptRuleTester.run('no-extraneous-dependencies typescript type imports', rule, { valid: [ diff --git a/utils/resolve.js b/utils/resolve.js index 27d5dcc1e..7c37c5d98 100644 --- a/utils/resolve.js +++ b/utils/resolve.js @@ -173,6 +173,7 @@ function resolverReducer(resolvers, map) { err.name = ERROR_NAME; throw err; } +exports.resolverReducer = resolverReducer; function getBaseDir(sourceFile) { return pkgDir.sync(sourceFile) || process.cwd(); @@ -196,6 +197,7 @@ function requireResolver(name, sourceFile) { return resolver; } +exports.requireResolver = requireResolver; function isResolverValid(resolver) { if (resolver.interfaceVersion === 2) {