From 3d7a551de617188079bf99c9478158adace02fac Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 14 Mar 2024 17:58:43 +0800 Subject: [PATCH] refactor: migrate more rules (#48) --- .changeset/dull-spoons-fly.md | 5 + package.json | 1 + patches/@typescript-eslint+utils+5.62.0.patch | 21 +- src/core/import-type.ts | 2 +- src/export-map.ts | 10 +- src/import-declaration.ts | 8 +- src/index.ts | 60 ++-- ....js => consistent-type-specifier-style.ts} | 87 ++++-- src/rules/default.ts | 1 - src/rules/{export.js => export.ts} | 156 ++++++---- src/rules/extensions.js | 205 ------------ src/rules/extensions.ts | 246 +++++++++++++++ src/rules/group-exports.js | 168 ---------- src/rules/group-exports.ts | 176 +++++++++++ src/rules/named.ts | 5 +- src/rules/{namespace.js => namespace.ts} | 202 ++++++++---- ...rnal-modules.js => no-internal-modules.ts} | 117 +++---- src/rules/no-mutable-exports.js | 62 ---- src/rules/no-mutable-exports.ts | 70 +++++ src/rules/no-namespace.js | 207 ------------- src/rules/no-namespace.ts | 206 ++++++++++++ ...ve-packages.js => no-relative-packages.ts} | 47 ++- src/rules/no-relative-parent-imports.js | 54 ---- src/rules/no-relative-parent-imports.ts | 67 ++++ ...ricted-paths.js => no-restricted-paths.ts} | 190 +++++++----- src/rules/no-unresolved.ts | 19 +- src/utils/module-visitor.ts | 10 +- src/utils/read-pkg-ip.ts | 4 +- ...> consistent-type-specifier-style.spec.ts} | 173 +++++------ test/rules/{export.spec.js => export.spec.ts} | 293 +++++++++--------- ...{extensions.spec.js => extensions.spec.ts} | 8 +- ...-exports.spec.js => group-exports.spec.ts} | 19 +- .../{namespace.spec.js => namespace.spec.ts} | 97 +++--- ...es.spec.js => no-internal-modules.spec.ts} | 7 +- ...rts.spec.js => no-mutable-exports.spec.ts} | 20 +- test/rules/no-namespace.spec.js | 144 --------- test/rules/no-namespace.spec.ts | 138 +++++++++ ...s.spec.js => no-relative-packages.spec.ts} | 8 +- ....js => no-relative-parent-imports.spec.ts} | 23 +- ...hs.spec.js => no-restricted-paths.spec.ts} | 63 ++-- test/utils.ts | 12 +- yarn.lock | 5 + 42 files changed, 1857 insertions(+), 1559 deletions(-) create mode 100644 .changeset/dull-spoons-fly.md rename src/rules/{consistent-type-specifier-style.js => consistent-type-specifier-style.ts} (76%) rename src/rules/{export.js => export.ts} (62%) delete mode 100644 src/rules/extensions.js create mode 100644 src/rules/extensions.ts delete mode 100644 src/rules/group-exports.js create mode 100644 src/rules/group-exports.ts rename src/rules/{namespace.js => namespace.ts} (55%) rename src/rules/{no-internal-modules.js => no-internal-modules.ts} (64%) delete mode 100644 src/rules/no-mutable-exports.js create mode 100644 src/rules/no-mutable-exports.ts delete mode 100644 src/rules/no-namespace.js create mode 100644 src/rules/no-namespace.ts rename src/rules/{no-relative-packages.js => no-relative-packages.ts} (66%) delete mode 100644 src/rules/no-relative-parent-imports.js create mode 100644 src/rules/no-relative-parent-imports.ts rename src/rules/{no-restricted-paths.js => no-restricted-paths.ts} (56%) rename test/rules/{consistent-type-specifier-style.spec.js => consistent-type-specifier-style.spec.ts} (68%) rename test/rules/{export.spec.js => export.spec.ts} (75%) rename test/rules/{extensions.spec.js => extensions.spec.ts} (99%) rename test/rules/{group-exports.spec.js => group-exports.spec.ts} (93%) rename test/rules/{namespace.spec.js => namespace.spec.ts} (86%) rename test/rules/{no-internal-modules.spec.js => no-internal-modules.spec.ts} (98%) rename test/rules/{no-mutable-exports.spec.js => no-mutable-exports.spec.ts} (92%) delete mode 100644 test/rules/no-namespace.spec.js create mode 100644 test/rules/no-namespace.spec.ts rename test/rules/{no-relative-packages.spec.js => no-relative-packages.spec.ts} (94%) rename test/rules/{no-relative-parent-imports.spec.js => no-relative-parent-imports.spec.ts} (89%) rename test/rules/{no-restricted-paths.spec.js => no-restricted-paths.spec.ts} (96%) diff --git a/.changeset/dull-spoons-fly.md b/.changeset/dull-spoons-fly.md new file mode 100644 index 000000000..ba8bf800f --- /dev/null +++ b/.changeset/dull-spoons-fly.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-import-x": patch +--- + +refactor: migrate more rules diff --git a/package.json b/package.json index a468f926d..881872314 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@types/debug": "^4.1.12", "@types/doctrine": "^0.0.9", "@types/eslint": "^8.56.5", + "@types/is-glob": "^4.0.4", "@types/jest": "^29.5.12", "@types/json-schema": "^7.0.15", "@types/node": "^20.11.26", diff --git a/patches/@typescript-eslint+utils+5.62.0.patch b/patches/@typescript-eslint+utils+5.62.0.patch index 7ae6c47d3..b1b15c6c6 100644 --- a/patches/@typescript-eslint+utils+5.62.0.patch +++ b/patches/@typescript-eslint+utils+5.62.0.patch @@ -1,15 +1,24 @@ diff --git a/node_modules/@typescript-eslint/utils/dist/ts-eslint/Rule.d.ts b/node_modules/@typescript-eslint/utils/dist/ts-eslint/Rule.d.ts -index 9a3a1fd..46b3961 100644 +index 9a3a1fd..6a2e2dd 100644 --- a/node_modules/@typescript-eslint/utils/dist/ts-eslint/Rule.d.ts +++ b/node_modules/@typescript-eslint/utils/dist/ts-eslint/Rule.d.ts -@@ -6,6 +6,10 @@ import type { Scope } from './Scope'; - import type { SourceCode } from './SourceCode'; +@@ -7,15 +7,13 @@ import type { SourceCode } from './SourceCode'; export type RuleRecommendation = 'error' | 'strict' | 'warn' | false; interface RuleMetaDataDocs { -+ /** + /** +- * Concise description of the rule + * The category the rule falls under -+ */ + */ +- description: string; + category?: string; /** - * Concise description of the rule +- * The recommendation level for the rule. +- * Used by the build tools to generate the recommended and strict configs. +- * Set to false to not include it as a recommendation ++ * Concise description of the rule + */ +- recommended: 'error' | 'strict' | 'warn' | false; ++ description: string; + /** + * The URL of the rule's docs */ diff --git a/src/core/import-type.ts b/src/core/import-type.ts index 676ba5574..2a304e60b 100644 --- a/src/core/import-type.ts +++ b/src/core/import-type.ts @@ -85,7 +85,7 @@ function isModuleMain(name: string) { const scopedRegExp = /^@[^/]+\/?[^/]+/ export function isScoped(name: string) { - return name && scopedRegExp.test(name) + return !!name && scopedRegExp.test(name) } const scopedMainRegExp = /^@[^/]+\/?[^/]+$/ diff --git a/src/export-map.ts b/src/export-map.ts index be5d3f981..aae8718b5 100644 --- a/src/export-map.ts +++ b/src/export-map.ts @@ -814,8 +814,12 @@ export class ExportMap { } } - forEach( - callback: (value: unknown, name: string, map: ExportMap) => void, + forEach( + callback: ( + value: T | null | undefined, + name: string, + map: ExportMap, + ) => void, thisArg?: unknown, ) { this.namespace.forEach((v, n) => { @@ -835,7 +839,7 @@ export class ExportMap { return } - d.forEach((v, n) => { + d.forEach((v, n) => { if (n !== 'default') { callback.call(thisArg, v, n, this) } diff --git a/src/import-declaration.ts b/src/import-declaration.ts index eacdb4ed4..d1705022e 100644 --- a/src/import-declaration.ts +++ b/src/import-declaration.ts @@ -1,6 +1,8 @@ -import type { Rule } from 'eslint' +import { TSESTree } from '@typescript-eslint/utils' -export const importDeclaration = (context: Rule.RuleContext) => { +import { RuleContext } from './types' + +export const importDeclaration = (context: RuleContext) => { const ancestors = context.getAncestors() - return ancestors[ancestors.length - 1] + return ancestors[ancestors.length - 1] as TSESTree.ImportDeclaration } diff --git a/src/index.ts b/src/index.ts index 294b628c7..81018abd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,25 +2,47 @@ import type { TSESLint } from '@typescript-eslint/utils' import type { PluginConfig } from './types' +// rules import noUnresolved from './rules/no-unresolved' import named from './rules/named' import default_ from './rules/default' +import namespace from './rules/namespace' +import noNamespace from './rules/no-namespace' +import export_ from './rules/export' +import noMutableExports from './rules/no-mutable-exports' +import extensions from './rules/extensions' +import noRestrictedPaths from './rules/no-restricted-paths' +import noInternalModules from './rules/no-internal-modules' +import groupExports from './rules/group-exports' +import noRelativePackages from './rules/no-relative-packages' +import noRelativeParentImports from './rules/no-relative-parent-imports' +import consistentTypeSpecifierStyle from './rules/consistent-type-specifier-style' + +// configs +import recommended from './config/recommended' +import errors from './config/errors' +import warnings from './config/warnings' +import stage0 from './config/stage-0' +import react from './config/react' +import reactNative from './config/react-native' +import electron from './config/electron' +import typescript from './config/typescript' export const rules = { 'no-unresolved': noUnresolved, named, default: default_, - namespace: require('./rules/namespace'), - 'no-namespace': require('./rules/no-namespace'), - export: require('./rules/export'), - 'no-mutable-exports': require('./rules/no-mutable-exports'), - extensions: require('./rules/extensions'), - 'no-restricted-paths': require('./rules/no-restricted-paths'), - 'no-internal-modules': require('./rules/no-internal-modules'), - 'group-exports': require('./rules/group-exports'), - 'no-relative-packages': require('./rules/no-relative-packages'), - 'no-relative-parent-imports': require('./rules/no-relative-parent-imports'), - 'consistent-type-specifier-style': require('./rules/consistent-type-specifier-style'), + namespace, + 'no-namespace': noNamespace, + export: export_, + 'no-mutable-exports': noMutableExports, + extensions, + 'no-restricted-paths': noRestrictedPaths, + 'no-internal-modules': noInternalModules, + 'group-exports': groupExports, + 'no-relative-packages': noRelativePackages, + 'no-relative-parent-imports': noRelativeParentImports, + 'consistent-type-specifier-style': consistentTypeSpecifierStyle, 'no-self-import': require('./rules/no-self-import'), 'no-cycle': require('./rules/no-cycle'), @@ -63,17 +85,17 @@ export const rules = { } satisfies Record> export const configs = { - recommended: require('./config/recommended'), + recommended, - errors: require('./config/errors'), - warnings: require('./config/warnings'), + errors, + warnings, // shhhh... work in progress "secret" rules - 'stage-0': require('./config/stage-0'), + 'stage-0': stage0, // useful stuff for folks using various environments - react: require('./config/react'), - 'react-native': require('./config/react-native'), - electron: require('./config/electron'), - typescript: require('./config/typescript'), + react, + 'react-native': reactNative, + electron, + typescript, } satisfies Record diff --git a/src/rules/consistent-type-specifier-style.js b/src/rules/consistent-type-specifier-style.ts similarity index 76% rename from src/rules/consistent-type-specifier-style.js rename to src/rules/consistent-type-specifier-style.ts index 067889138..6e0cbe5cd 100644 --- a/src/rules/consistent-type-specifier-style.js +++ b/src/rules/consistent-type-specifier-style.ts @@ -1,10 +1,16 @@ -import { docsUrl } from '../docs-url' +import { TSESLint, TSESTree } from '@typescript-eslint/utils' +import { createRule } from '../utils' -function isComma(token) { +function isComma(token: TSESTree.Token): token is TSESTree.PunctuatorToken { return token.type === 'Punctuator' && token.value === ',' } -function removeSpecifiers(fixes, fixer, sourceCode, specifiers) { +function removeSpecifiers( + fixes: TSESLint.RuleFix[], + fixer: TSESLint.RuleFixer, + sourceCode: Readonly, + specifiers: TSESTree.ImportSpecifier[], +) { for (const specifier of specifiers) { // remove the trailing comma const token = sourceCode.getTokenAfter(specifier) @@ -15,7 +21,12 @@ function removeSpecifiers(fixes, fixer, sourceCode, specifiers) { } } -function getImportText(node, sourceCode, specifiers, kind) { +function getImportText( + node: TSESTree.ImportDeclaration, + sourceCode: Readonly, + specifiers: TSESTree.ImportSpecifier[], + kind: 'type' | 'typeof', +) { const sourceString = sourceCode.getText(node.source) if (specifiers.length === 0) { return '' @@ -31,14 +42,18 @@ function getImportText(node, sourceCode, specifiers, kind) { return `import ${kind} {${names.join(', ')}} from ${sourceString};` } -module.exports = { +type Options = 'prefer-inline' | 'prefer-top-level' + +type MessageId = 'inline' | 'topLevel' + +export = createRule<[Options?], MessageId>({ + name: 'consistent-type-specifier-style', meta: { type: 'suggestion', docs: { category: 'Style guide', description: 'Enforce or ban the use of inline type-only markers for named imports.', - url: docsUrl('consistent-type-specifier-style'), }, fixable: 'code', schema: [ @@ -48,8 +63,14 @@ module.exports = { default: 'prefer-inline', }, ], + messages: { + inline: + 'Prefer using inline {{kind}} specifiers instead of a top-level {{kind}}-only import.', + topLevel: + 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.', + }, }, - + defaultOptions: [], create(context) { const sourceCode = context.getSourceCode() @@ -75,20 +96,19 @@ module.exports = { context.report({ node, - message: - 'Prefer using inline {{kind}} specifiers instead of a top-level {{kind}}-only import.', + messageId: 'inline', data: { kind: node.importKind, }, fix(fixer) { const kindToken = sourceCode.getFirstToken(node, { skip: 1 }) - return [].concat( + return [ kindToken ? fixer.remove(kindToken) : [], node.specifiers.map(specifier => fixer.insertTextBefore(specifier, `${node.importKind} `), ), - ) + ].flat() }, }) }, @@ -101,6 +121,7 @@ module.exports = { if ( // already top-level is valid node.importKind === 'type' || + // @ts-expect-error - flow type node.importKind === 'typeof' || // no specifiers (import {} from '') cannot have inline - so is valid node.specifiers.length === 0 || @@ -113,19 +134,28 @@ module.exports = { return } - const typeSpecifiers = [] - const typeofSpecifiers = [] - const valueSpecifiers = [] - let defaultSpecifier = null + const typeSpecifiers: TSESTree.ImportSpecifier[] = [] + const typeofSpecifiers: TSESTree.ImportSpecifier[] = [] + const valueSpecifiers: TSESTree.ImportSpecifier[] = [] + + let defaultSpecifier: TSESTree.ImportDefaultSpecifier | null = null + for (const specifier of node.specifiers) { if (specifier.type === 'ImportDefaultSpecifier') { defaultSpecifier = specifier continue } + if (!('importKind' in specifier)) { + continue + } + if (specifier.importKind === 'type') { typeSpecifiers.push(specifier) - } else if (specifier.importKind === 'typeof') { + } else if ( + // @ts-expect-error - flow type + specifier.importKind === 'typeof' + ) { typeofSpecifiers.push(specifier) } else if ( specifier.importKind === 'value' || @@ -154,15 +184,14 @@ module.exports = { node.specifiers.length ) { // all specifiers have inline specifiers - so we replace the entire import - const kind = [].concat( - typeSpecifiers.length > 0 ? 'type' : [], - typeofSpecifiers.length > 0 ? 'typeof' : [], - ) + const kind = [ + typeSpecifiers.length > 0 ? ('type' as const) : [], + typeofSpecifiers.length > 0 ? ('typeof' as const) : [], + ].flat() context.report({ node, - message: - 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.', + messageId: 'topLevel', data: { kind: kind.join('/'), }, @@ -175,13 +204,12 @@ module.exports = { for (const specifier of typeSpecifiers.concat(typeofSpecifiers)) { context.report({ node: specifier, - message: - 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.', + messageId: 'topLevel', data: { kind: specifier.importKind, }, fix(fixer) { - const fixes = [] + const fixes: TSESLint.RuleFix[] = [] // if there are no value specifiers, then the other report fixer will be called, not this one @@ -201,7 +229,7 @@ module.exports = { // import { Value, } from 'mod'; const maybeComma = sourceCode.getTokenAfter( valueSpecifiers[valueSpecifiers.length - 1], - ) + )! if (isComma(maybeComma)) { fixes.push(fixer.remove(maybeComma)) } @@ -220,7 +248,10 @@ module.exports = { token => token.type === 'Punctuator' && token.value === '}', ) fixes.push( - fixer.removeRange([comma.range[0], closingBrace.range[1]]), + fixer.removeRange([ + comma!.range[0], + closingBrace!.range[1], + ]), ) } @@ -235,4 +266,4 @@ module.exports = { }, } }, -} +}) diff --git a/src/rules/default.ts b/src/rules/default.ts index 55c5193d8..e4f4da07d 100644 --- a/src/rules/default.ts +++ b/src/rules/default.ts @@ -12,7 +12,6 @@ export = createRule<[], MessageId>({ category: 'Static analysis', description: 'Ensure a default export is present, given a default import.', - recommended: 'warn', }, schema: [], messages: { diff --git a/src/rules/export.js b/src/rules/export.ts similarity index 62% rename from src/rules/export.js rename to src/rules/export.ts index 7504db501..a93aa5f93 100644 --- a/src/rules/export.js +++ b/src/rules/export.ts @@ -1,5 +1,6 @@ +import { TSESTree } from '@typescript-eslint/utils' import { ExportMap, recursivePatternCapture } from '../export-map' -import { docsUrl } from '../docs-url' +import { createRule } from '../utils' /* Notes on TypeScript namespaces aka TSModuleDeclaration: @@ -30,24 +31,21 @@ const tsTypePrefix = 'type:' * export function foo(a: string); * export function foo(a: number|string) { return a; } * ``` - * @param {Set} nodes - * @returns {boolean} */ -function isTypescriptFunctionOverloads(nodes) { +function isTypescriptFunctionOverloads(nodes: Set) { const nodesArr = Array.from(nodes) const idents = nodesArr.flatMap(node => - node.declaration && - (node.declaration.type === 'TSDeclareFunction' || // eslint 6+ - node.declaration.type === 'TSEmptyBodyFunctionDeclaration') // eslint 4-5 - ? node.declaration.id.name + 'declaration' in node && node.declaration?.type === 'TSDeclareFunction' + ? node.declaration.id!.name : [], ) + if (new Set(idents).size !== idents.length) { return true } - const types = new Set(nodesArr.map(node => node.parent.type)) + const types = new Set(nodesArr.map(node => `${node.parent!.type}` as const)) if (!types.has('TSDeclareFunction')) { return false } @@ -66,13 +64,13 @@ function isTypescriptFunctionOverloads(nodes) { * export class Foo { } * export namespace Foo { } * ``` - * @param {Set} nodes - * @returns {boolean} */ -function isTypescriptNamespaceMerging(nodes) { - const types = new Set(Array.from(nodes, node => node.parent.type)) +function isTypescriptNamespaceMerging(nodes: Set) { + const types = new Set( + Array.from(nodes, node => `${node.parent!.type}` as const), + ) const noNamespaceNodes = Array.from(nodes).filter( - node => node.parent.type !== 'TSModuleDeclaration', + node => node.parent!.type !== 'TSModuleDeclaration', ) return ( @@ -98,16 +96,18 @@ function isTypescriptNamespaceMerging(nodes) { * export function Foo(); * export namespace Foo { } * ``` - * @param {Object} node - * @param {Set} nodes - * @returns {boolean} */ -function shouldSkipTypescriptNamespace(node, nodes) { - const types = new Set(Array.from(nodes, node => node.parent.type)) +function shouldSkipTypescriptNamespace( + node: TSESTree.Node, + nodes: Set, +) { + const types = new Set( + Array.from(nodes, node => `${node.parent!.type}` as const), + ) return ( !isTypescriptNamespaceMerging(nodes) && - node.parent.type === 'TSModuleDeclaration' && + node.parent!.type === 'TSModuleDeclaration' && (types.has('TSEnumDeclaration') || types.has('ClassDeclaration') || types.has('FunctionDeclaration') || @@ -115,28 +115,45 @@ function shouldSkipTypescriptNamespace(node, nodes) { ) } -module.exports = { +type MessageId = 'noNamed' | 'multiDefault' | 'multiNamed' + +export = createRule<[], MessageId>({ + name: 'export', meta: { type: 'problem', docs: { category: 'Helpful warnings', description: 'Forbid any invalid exports, i.e. re-export of the same name.', - url: docsUrl('export'), }, schema: [], + messages: { + noNamed: "No named exports found in module '{{module}}'.", + multiDefault: 'Multiple default exports.', + multiNamed: "Multiple exports of name '{{name}}'.", + }, }, - + defaultOptions: [], create(context) { - const namespace = new Map([[rootProgram, new Map()]]) - - function addNamed(name, node, parent, isType) { + const namespace = new Map< + 'root' | TSESTree.TSModuleDeclaration, + Map> + >([[rootProgram, new Map()]]) + + function addNamed( + name: string, + node: TSESTree.Node, + parent: TSESTree.TSModuleDeclaration | 'root', + isType?: boolean, + ) { if (!namespace.has(parent)) { namespace.set(parent, new Map()) } - const named = namespace.get(parent) + + const named = namespace.get(parent)! const key = isType ? `${tsTypePrefix}${name}` : name + let nodes = named.get(key) if (nodes == null) { @@ -147,9 +164,9 @@ module.exports = { nodes.add(node) } - function getParent(node) { - if (node.parent && node.parent.type === 'TSModuleBlock') { - return node.parent.parent + function getParent(node: TSESTree.Node) { + if (node.parent?.type === 'TSModuleBlock') { + return node.parent.parent as TSESTree.TSModuleDeclaration } // just in case somehow a non-ts namespace export declaration isn't directly @@ -164,9 +181,11 @@ module.exports = { ExportSpecifier(node) { addNamed( - node.exported.name || node.exported.value, + node.exported.name || + // @ts-expect-error - legacy parser type + node.exported.value, node.exported, - getParent(node.parent), + getParent(node.parent!), ) }, @@ -176,35 +195,36 @@ module.exports = { } const parent = getParent(node) - // support for old TypeScript versions - const isTypeVariableDecl = node.declaration.kind === 'type' - if (node.declaration.id != null) { - if ( + const isTypeVariableDecl = + 'kind' in node.declaration && + // @ts-expect-error - support for old TypeScript versions + node.declaration.kind === 'type' + + if ('id' in node.declaration && node.declaration.id != null) { + const id = node.declaration.id as TSESTree.Identifier + addNamed( + id.name, + id, + parent, ['TSTypeAliasDeclaration', 'TSInterfaceDeclaration'].includes( node.declaration.type, - ) - ) { - addNamed( - node.declaration.id.name, - node.declaration.id, - parent, - true, - ) - } else { - addNamed( - node.declaration.id.name, - node.declaration.id, - parent, - isTypeVariableDecl, - ) - } + ) || isTypeVariableDecl, + ) } - if (node.declaration.declarations != null) { + if ( + 'declarations' in node.declaration && + node.declaration.declarations != null + ) { for (const declaration of node.declaration.declarations) { recursivePatternCapture(declaration.id, v => { - addNamed(v.name, v, parent, isTypeVariableDecl) + addNamed( + (v as TSESTree.Identifier).name, + v, + parent, + isTypeVariableDecl, + ) }) } } @@ -233,7 +253,8 @@ module.exports = { const parent = getParent(node) let any = false - remoteExports.forEach((v, name) => { + + remoteExports.forEach((_, name) => { if (name !== 'default') { any = true // poor man's filter addNamed(name, node, parent) @@ -241,10 +262,11 @@ module.exports = { }) if (!any) { - context.report( - node.source, - `No named exports found in module '${node.source.value}'.`, - ) + context.report({ + node: node.source, + messageId: 'noNamed', + data: { module: node.source.value }, + }) } }, @@ -268,12 +290,18 @@ module.exports = { } if (name === 'default') { - context.report(node, 'Multiple default exports.') + context.report({ + node, + messageId: 'multiDefault', + }) } else { - context.report( + context.report({ node, - `Multiple exports of name '${name.replace(tsTypePrefix, '')}'.`, - ) + messageId: 'multiNamed', + data: { + name: name.replace(tsTypePrefix, ''), + }, + }) } } } @@ -281,4 +309,4 @@ module.exports = { }, } }, -} +}) diff --git a/src/rules/extensions.js b/src/rules/extensions.js deleted file mode 100644 index caf504175..000000000 --- a/src/rules/extensions.js +++ /dev/null @@ -1,205 +0,0 @@ -import path from 'path' - -import { resolve } from '../utils/resolve' -import { isBuiltIn, isExternalModule, isScoped } from '../core/import-type' -import { moduleVisitor } from '../utils/module-visitor' -import { docsUrl } from '../docs-url' - -const enumValues = { enum: ['always', 'ignorePackages', 'never'] } -const patternProperties = { - type: 'object', - patternProperties: { '.*': enumValues }, -} -const properties = { - type: 'object', - properties: { - pattern: patternProperties, - ignorePackages: { type: 'boolean' }, - }, -} - -function buildProperties(context) { - const result = { - defaultConfig: 'never', - pattern: {}, - ignorePackages: false, - } - - context.options.forEach(obj => { - // If this is a string, set defaultConfig to its value - if (typeof obj === 'string') { - result.defaultConfig = obj - return - } - - // If this is not the new structure, transfer all props to result.pattern - if (obj.pattern === undefined && obj.ignorePackages === undefined) { - Object.assign(result.pattern, obj) - return - } - - // If pattern is provided, transfer all props - if (obj.pattern !== undefined) { - Object.assign(result.pattern, obj.pattern) - } - - // If ignorePackages is provided, transfer it to result - if (obj.ignorePackages !== undefined) { - result.ignorePackages = obj.ignorePackages - } - }) - - if (result.defaultConfig === 'ignorePackages') { - result.defaultConfig = 'always' - result.ignorePackages = true - } - - return result -} - -module.exports = { - meta: { - type: 'suggestion', - docs: { - category: 'Style guide', - description: - 'Ensure consistent use of file extension within the import path.', - url: docsUrl('extensions'), - }, - - schema: { - anyOf: [ - { - type: 'array', - items: [enumValues], - additionalItems: false, - }, - { - type: 'array', - items: [enumValues, properties], - additionalItems: false, - }, - { - type: 'array', - items: [properties], - additionalItems: false, - }, - { - type: 'array', - items: [patternProperties], - additionalItems: false, - }, - { - type: 'array', - items: [enumValues, patternProperties], - additionalItems: false, - }, - ], - }, - }, - - create(context) { - const props = buildProperties(context) - - function getModifier(extension) { - return props.pattern[extension] || props.defaultConfig - } - - function isUseOfExtensionRequired(extension, isPackage) { - return ( - getModifier(extension) === 'always' && - (!props.ignorePackages || !isPackage) - ) - } - - function isUseOfExtensionForbidden(extension) { - return getModifier(extension) === 'never' - } - - function isResolvableWithoutExtension(file) { - const extension = path.extname(file) - const fileWithoutExtension = file.slice(0, -extension.length) - const resolvedFileWithoutExtension = resolve( - fileWithoutExtension, - context, - ) - - return resolvedFileWithoutExtension === resolve(file, context) - } - - function isExternalRootModule(file) { - if (file === '.' || file === '..') { - return false - } - const slashCount = file.split('/').length - 1 - - if (slashCount === 0) { - return true - } - if (isScoped(file) && slashCount <= 1) { - return true - } - return false - } - - function checkFileExtension(source, node) { - // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor - if (!source || !source.value) { - return - } - - const importPathWithQueryString = source.value - - // don't enforce anything on builtins - if (isBuiltIn(importPathWithQueryString, context.settings)) { - return - } - - const importPath = importPathWithQueryString.replace(/\?(.*)$/, '') - - // don't enforce in root external packages as they may have names with `.js`. - // Like `import Decimal from decimal.js`) - if (isExternalRootModule(importPath)) { - return - } - - const resolvedPath = resolve(importPath, context) - - // get extension from resolved path, if possible. - // for unresolved, use source value. - const extension = path.extname(resolvedPath || importPath).substring(1) - - // determine if this is a module - const isPackage = - isExternalModule(importPath, resolve(importPath, context), context) || - isScoped(importPath) - - if (!extension || !importPath.endsWith(`.${extension}`)) { - // ignore type-only imports and exports - if (node.importKind === 'type' || node.exportKind === 'type') { - return - } - const extensionRequired = isUseOfExtensionRequired(extension, isPackage) - const extensionForbidden = isUseOfExtensionForbidden(extension) - if (extensionRequired && !extensionForbidden) { - context.report({ - node: source, - message: `Missing file extension ${extension ? `"${extension}" ` : ''}for "${importPathWithQueryString}"`, - }) - } - } else if (extension) { - if ( - isUseOfExtensionForbidden(extension) && - isResolvableWithoutExtension(importPath) - ) { - context.report({ - node: source, - message: `Unexpected use of file extension "${extension}" for "${importPathWithQueryString}"`, - }) - } - } - } - - return moduleVisitor(checkFileExtension, { commonjs: true }) - }, -} diff --git a/src/rules/extensions.ts b/src/rules/extensions.ts new file mode 100644 index 000000000..09565347f --- /dev/null +++ b/src/rules/extensions.ts @@ -0,0 +1,246 @@ +import path from 'path' + +import { resolve } from '../utils/resolve' +import { isBuiltIn, isExternalModule, isScoped } from '../core/import-type' +import { moduleVisitor } from '../utils/module-visitor' +import { createRule } from '../utils' +import { FileExtension, RuleContext } from '../types' + +const enumValues = { enum: ['always', 'ignorePackages', 'never'] as const } + +const patternProperties = { + type: 'object', + patternProperties: { '.*': enumValues }, +} + +const properties = { + type: 'object', + properties: { + pattern: patternProperties, + ignorePackages: { type: 'boolean' }, + }, +} + +type DefaultConfig = (typeof enumValues)['enum'][number] + +type MessageId = 'missing' | 'unexpected' + +type NormalizedOptions = { + defaultConfig?: DefaultConfig + pattern?: Record + ignorePackages?: boolean +} + +type Options = DefaultConfig | NormalizedOptions + +function buildProperties(context: RuleContext) { + const result: Required = { + defaultConfig: 'never', + pattern: {}, + ignorePackages: false, + } + + context.options.forEach(obj => { + // If this is a string, set defaultConfig to its value + if (typeof obj === 'string') { + result.defaultConfig = obj + return + } + + // If this is not the new structure, transfer all props to result.pattern + if (obj.pattern === undefined && obj.ignorePackages === undefined) { + Object.assign(result.pattern, obj) + return + } + + // If pattern is provided, transfer all props + if (obj.pattern !== undefined) { + Object.assign(result.pattern, obj.pattern) + } + + // If ignorePackages is provided, transfer it to result + if (obj.ignorePackages !== undefined) { + result.ignorePackages = obj.ignorePackages + } + }) + + if (result.defaultConfig === 'ignorePackages') { + result.defaultConfig = 'always' + result.ignorePackages = true + } + + return result +} + +export = createRule({ + name: 'extensions', + meta: { + type: 'suggestion', + docs: { + category: 'Style guide', + description: + 'Ensure consistent use of file extension within the import path.', + }, + schema: { + anyOf: [ + { + type: 'array', + items: [enumValues], + additionalItems: false, + }, + { + type: 'array', + items: [enumValues, properties], + additionalItems: false, + }, + { + type: 'array', + items: [properties], + additionalItems: false, + }, + { + type: 'array', + items: [patternProperties], + additionalItems: false, + }, + { + type: 'array', + items: [enumValues, patternProperties], + additionalItems: false, + }, + ], + }, + messages: { + missing: 'Missing file extension {{extension}}for "{{importPath}}"', + unexpected: + 'Unexpected use of file extension "{{extension}}" for "{{importPath}}"', + }, + }, + defaultOptions: [], + create(context) { + const props = buildProperties(context) + + function getModifier(extension: FileExtension) { + return props.pattern[extension] || props.defaultConfig + } + + function isUseOfExtensionRequired( + extension: FileExtension, + isPackage: boolean, + ) { + return ( + getModifier(extension) === 'always' && + (!props.ignorePackages || !isPackage) + ) + } + + function isUseOfExtensionForbidden(extension: FileExtension) { + return getModifier(extension) === 'never' + } + + function isResolvableWithoutExtension(file: string) { + const extension = path.extname(file) + const fileWithoutExtension = file.slice(0, -extension.length) + const resolvedFileWithoutExtension = resolve( + fileWithoutExtension, + context, + ) + return resolvedFileWithoutExtension === resolve(file, context) + } + + function isExternalRootModule(file: string) { + if (file === '.' || file === '..') { + return false + } + const slashCount = file.split('/').length - 1 + + if (slashCount === 0) { + return true + } + if (isScoped(file) && slashCount <= 1) { + return true + } + return false + } + + return moduleVisitor( + (source, node) => { + // bail if the declaration doesn't have a source, e.g. "export { foo };", or if it's only partially typed like in an editor + if (!source || !source.value) { + return + } + + const importPathWithQueryString = source.value + + // don't enforce anything on builtins + if (isBuiltIn(importPathWithQueryString, context.settings)) { + return + } + + const importPath = importPathWithQueryString.replace(/\?(.*)$/, '') + + // don't enforce in root external packages as they may have names with `.js`. + // Like `import Decimal from decimal.js`) + if (isExternalRootModule(importPath)) { + return + } + + const resolvedPath = resolve(importPath, context) + + // get extension from resolved path, if possible. + // for unresolved, use source value. + const extension = path + .extname(resolvedPath || importPath) + .substring(1) as FileExtension + + // determine if this is a module + const isPackage = + isExternalModule( + importPath, + resolve(importPath, context)!, + context, + ) || isScoped(importPath) + + if (!extension || !importPath.endsWith(`.${extension}`)) { + // ignore type-only imports and exports + if ( + ('importKind' in node && node.importKind === 'type') || + ('exportKind' in node && node.exportKind === 'type') + ) { + return + } + const extensionRequired = isUseOfExtensionRequired( + extension, + isPackage, + ) + const extensionForbidden = isUseOfExtensionForbidden(extension) + if (extensionRequired && !extensionForbidden) { + context.report({ + node: source, + messageId: 'missing', + data: { + extension: extension ? `"${extension}" ` : '', + importPath: importPathWithQueryString, + }, + }) + } + } else if (extension) { + if ( + isUseOfExtensionForbidden(extension) && + isResolvableWithoutExtension(importPath) + ) { + context.report({ + node: source, + messageId: 'unexpected', + data: { + extension, + importPath: importPathWithQueryString, + }, + }) + } + } + }, + { commonjs: true }, + ) + }, +}) diff --git a/src/rules/group-exports.js b/src/rules/group-exports.js deleted file mode 100644 index 8f32a1086..000000000 --- a/src/rules/group-exports.js +++ /dev/null @@ -1,168 +0,0 @@ -import { docsUrl } from '../docs-url' - -const meta = { - type: 'suggestion', - docs: { - category: 'Style guide', - description: - 'Prefer named exports to be grouped together in a single export declaration', - url: docsUrl('group-exports'), - }, -} -/* eslint-disable max-len */ -const errors = { - ExportNamedDeclaration: - 'Multiple named export declarations; consolidate all named exports into a single export declaration', - AssignmentExpression: - 'Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports`', -} -/* eslint-enable max-len */ - -/** - * Returns an array with names of the properties in the accessor chain for MemberExpression nodes - * - * Example: - * - * `module.exports = {}` => ['module', 'exports'] - * `module.exports.property = true` => ['module', 'exports', 'property'] - * - * @param {Node} node AST Node (MemberExpression) - * @return {Array} Array with the property names in the chain - * @private - */ -function accessorChain(node) { - const chain = [] - - do { - chain.unshift(node.property.name) - - if (node.object.type === 'Identifier') { - chain.unshift(node.object.name) - break - } - - node = node.object - } while (node.type === 'MemberExpression') - - return chain -} - -function create(context) { - const nodes = { - modules: { - set: new Set(), - sources: {}, - }, - types: { - set: new Set(), - sources: {}, - }, - commonjs: { - set: new Set(), - }, - } - - return { - ExportNamedDeclaration(node) { - const target = node.exportKind === 'type' ? nodes.types : nodes.modules - if (!node.source) { - target.set.add(node) - } else if (Array.isArray(target.sources[node.source.value])) { - target.sources[node.source.value].push(node) - } else { - target.sources[node.source.value] = [node] - } - }, - - AssignmentExpression(node) { - if (node.left.type !== 'MemberExpression') { - return - } - - const chain = accessorChain(node.left) - - // Assignments to module.exports - // Deeper assignments are ignored since they just modify what's already being exported - // (ie. module.exports.exported.prop = true is ignored) - if ( - chain[0] === 'module' && - chain[1] === 'exports' && - chain.length <= 3 - ) { - nodes.commonjs.set.add(node) - return - } - - // Assignments to exports (exports.* = *) - if (chain[0] === 'exports' && chain.length === 2) { - nodes.commonjs.set.add(node) - return - } - }, - - 'Program:exit': function onExit() { - // Report multiple `export` declarations (ES2015 modules) - if (nodes.modules.set.size > 1) { - nodes.modules.set.forEach(node => { - context.report({ - node, - message: errors[node.type], - }) - }) - } - - // Report multiple `aggregated exports` from the same module (ES2015 modules) - Object.values(nodes.modules.sources) - .filter( - nodesWithSource => - Array.isArray(nodesWithSource) && nodesWithSource.length > 1, - ) - .flat() - .forEach(node => { - context.report({ - node, - message: errors[node.type], - }) - }) - - // Report multiple `export type` declarations (FLOW ES2015 modules) - if (nodes.types.set.size > 1) { - nodes.types.set.forEach(node => { - context.report({ - node, - message: errors[node.type], - }) - }) - } - - // Report multiple `aggregated type exports` from the same module (FLOW ES2015 modules) - Object.values(nodes.types.sources) - .filter( - nodesWithSource => - Array.isArray(nodesWithSource) && nodesWithSource.length > 1, - ) - .flat() - .forEach(node => { - context.report({ - node, - message: errors[node.type], - }) - }) - - // Report multiple `module.exports` assignments (CommonJS) - if (nodes.commonjs.set.size > 1) { - nodes.commonjs.set.forEach(node => { - context.report({ - node, - message: errors[node.type], - }) - }) - } - }, - } -} - -module.exports = { - meta, - create, -} diff --git a/src/rules/group-exports.ts b/src/rules/group-exports.ts new file mode 100644 index 000000000..812196ee4 --- /dev/null +++ b/src/rules/group-exports.ts @@ -0,0 +1,176 @@ +import type { TSESTree } from '@typescript-eslint/utils' + +import { createRule } from '../utils' + +type Literal = string | number | bigint | boolean | RegExp | null + +/** + * Returns an array with names of the properties in the accessor chain for MemberExpression nodes + * + * Example: + * + * `module.exports = {}` => ['module', 'exports'] + * `module.exports.property = true` => ['module', 'exports', 'property'] + */ +function accessorChain(node: TSESTree.MemberExpression) { + const chain: Literal[] = [] + + let exp: TSESTree.Expression = node + + do { + if ('name' in exp.property) { + chain.unshift(exp.property.name) + } else if ('value' in exp.property) { + chain.unshift(exp.property.value) + } + + if (exp.object.type === 'Identifier') { + chain.unshift(exp.object.name) + break + } + + exp = exp.object + } while (exp.type === 'MemberExpression') + + return chain +} + +export = createRule<[], 'ExportNamedDeclaration' | 'AssignmentExpression'>({ + name: 'group-exports', + meta: { + type: 'suggestion', + docs: { + category: 'Style guide', + description: + 'Prefer named exports to be grouped together in a single export declaration', + }, + schema: {}, + messages: { + ExportNamedDeclaration: + 'Multiple named export declarations; consolidate all named exports into a single export declaration', + AssignmentExpression: + 'Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports`', + }, + }, + defaultOptions: [], + create(context) { + const nodes = { + modules: { + set: new Set< + | TSESTree.ExportNamedDeclarationWithoutSourceWithMultiple + | TSESTree.ExportNamedDeclarationWithoutSourceWithSingle + >(), + sources: {} as Record, + }, + types: { + set: new Set< + | TSESTree.ExportNamedDeclarationWithoutSourceWithMultiple + | TSESTree.ExportNamedDeclarationWithoutSourceWithSingle + >(), + sources: {} as Record, + }, + commonjs: { + set: new Set(), + }, + } + + return { + ExportNamedDeclaration(node) { + const target = node.exportKind === 'type' ? nodes.types : nodes.modules + if (!node.source) { + target.set.add(node) + } else if (Array.isArray(target.sources[node.source.value])) { + target.sources[node.source.value].push(node) + } else { + target.sources[node.source.value] = [node] + } + }, + + AssignmentExpression(node) { + if (node.left.type !== 'MemberExpression') { + return + } + + const chain = accessorChain(node.left) + + // Assignments to module.exports + // Deeper assignments are ignored since they just modify what's already being exported + // (ie. module.exports.exported.prop = true is ignored) + if ( + chain[0] === 'module' && + chain[1] === 'exports' && + chain.length <= 3 + ) { + nodes.commonjs.set.add(node) + return + } + + // Assignments to exports (exports.* = *) + if (chain[0] === 'exports' && chain.length === 2) { + nodes.commonjs.set.add(node) + return + } + }, + + 'Program:exit': function onExit() { + // Report multiple `export` declarations (ES2015 modules) + if (nodes.modules.set.size > 1) { + nodes.modules.set.forEach(node => { + context.report({ + node, + messageId: node.type, + }) + }) + } + + // Report multiple `aggregated exports` from the same module (ES2015 modules) + Object.values(nodes.modules.sources) + .filter( + nodesWithSource => + Array.isArray(nodesWithSource) && nodesWithSource.length > 1, + ) + .flat() + .forEach(node => { + context.report({ + node, + messageId: node.type, + }) + }) + + // Report multiple `export type` declarations (FLOW ES2015 modules) + if (nodes.types.set.size > 1) { + nodes.types.set.forEach(node => { + context.report({ + node, + messageId: node.type, + }) + }) + } + + // Report multiple `aggregated type exports` from the same module (FLOW ES2015 modules) + Object.values(nodes.types.sources) + .filter( + nodesWithSource => + Array.isArray(nodesWithSource) && nodesWithSource.length > 1, + ) + .flat() + .forEach(node => { + context.report({ + node, + messageId: node.type, + }) + }) + + // Report multiple `module.exports` assignments (CommonJS) + if (nodes.commonjs.set.size > 1) { + nodes.commonjs.set.forEach(node => { + context.report({ + node, + messageId: node.type, + }) + }) + } + }, + } + }, +}) diff --git a/src/rules/named.ts b/src/rules/named.ts index a59b4bd42..c5b24152f 100644 --- a/src/rules/named.ts +++ b/src/rules/named.ts @@ -8,7 +8,7 @@ import { ModuleOptions } from '../utils/module-visitor' type MessageId = 'notFound' | 'notFoundDeep' -export = createRule<[ModuleOptions], MessageId>({ +export = createRule<[ModuleOptions?], MessageId>({ name: 'named', meta: { type: 'problem', @@ -16,7 +16,6 @@ export = createRule<[ModuleOptions], MessageId>({ category: 'Static analysis', description: 'Ensure named imports correspond to a named export in the remote file.', - recommended: 'error', }, messages: { notFound: "{{name}} not found in '{{path}}'", @@ -34,7 +33,7 @@ export = createRule<[ModuleOptions], MessageId>({ }, ], }, - defaultOptions: [{}], + defaultOptions: [], create(context) { const options = context.options[0] || {} diff --git a/src/rules/namespace.js b/src/rules/namespace.ts similarity index 55% rename from src/rules/namespace.js rename to src/rules/namespace.ts index 8e0e69de7..a973176e9 100644 --- a/src/rules/namespace.js +++ b/src/rules/namespace.ts @@ -1,9 +1,26 @@ import { declaredScope } from '../utils/declared-scope' import { ExportMap } from '../export-map' import { importDeclaration } from '../import-declaration' -import { docsUrl } from '../docs-url' +import { createRule } from '../utils' +import { RuleContext } from '../types' +import { TSESTree } from '@typescript-eslint/utils' + +type MessageId = + | 'noNamesFound' + | 'computedReference' + | 'namespaceMember' + | 'topLevelNames' + | 'notFoundInNamespace' + +type Options = { + allowComputed?: boolean +} -function processBodyStatement(context, namespaces, declaration) { +function processBodyStatement( + context: RuleContext, + namespaces: Map, + declaration: TSESTree.ProgramStatement, +) { if (declaration.type !== 'ImportDeclaration') { return } @@ -13,8 +30,9 @@ function processBodyStatement(context, namespaces, declaration) { } const imports = ExportMap.get(declaration.source.value, context) + if (imports == null) { - return null + return } if (imports.errors.length > 0) { @@ -26,20 +44,25 @@ function processBodyStatement(context, namespaces, declaration) { switch (specifier.type) { case 'ImportNamespaceSpecifier': if (!imports.size) { - context.report( - specifier, - `No exported names found in module '${declaration.source.value}'.`, - ) + context.report({ + node: specifier, + messageId: 'noNamesFound', + data: { + module: declaration.source.value, + }, + }) } namespaces.set(specifier.local.name, imports) break case 'ImportDefaultSpecifier': case 'ImportSpecifier': { - const meta = imports.get( - // default to 'default' for default https://i.imgur.com/nj6qAWy.jpg - specifier.imported - ? specifier.imported.name || specifier.imported.value - : 'default', + const meta = imports.get<{ namespace?: ExportMap }>( + 'imported' in specifier && specifier.imported + ? specifier.imported.name || + // @ts-expect-error - legacy parser node + specifier.imported.value + : // default to 'default' for default + 'default', ) if (!meta || !meta.namespace) { break @@ -52,16 +75,34 @@ function processBodyStatement(context, namespaces, declaration) { }) } -module.exports = { +function makeMessage( + last: + | TSESTree.Identifier + | TSESTree.PrivateIdentifier + | TSESTree.JSXIdentifier, + namepath: string[], + node: TSESTree.Node = last, +) { + return { + node, + messageId: 'notFoundInNamespace' as const, + data: { + name: last.name, + depth: namepath.length > 1 ? 'deeply ' : '', + namepath: namepath.join('.'), + }, + } +} + +export = createRule<[Options], MessageId>({ + name: 'namespace', meta: { type: 'problem', docs: { category: 'Static analysis', description: 'Ensure imported namespaces contain dereferenced properties as they are dereferenced.', - url: docsUrl('namespace'), }, - schema: [ { type: 'object', @@ -76,17 +117,26 @@ module.exports = { additionalProperties: false, }, ], + messages: { + noNamesFound: "No exported names found in module '{{module}}'.", + computedReference: + "Unable to validate computed reference to imported namespace '{{namespace}}'.", + namespaceMember: "Assignment to member of namespace '{{namespace}}'.", + topLevelNames: 'Only destructure top-level names.', + notFoundInNamespace: + "'{{name}}' not found in {{depth}}imported namespace '{{namepath}}'.", + }, }, - + defaultOptions: [ + { + allowComputed: false, + }, + ], create: function namespaceRule(context) { // read options - const { allowComputed = false } = context.options[0] || {} - - const namespaces = new Map() + const { allowComputed } = context.options[0] || {} - function makeMessage(last, namepath) { - return `'${last.name}' not found in ${namepath.length > 1 ? 'deeply ' : ''}imported namespace '${namepath.join('.')}'.` - } + const namespaces = new Map() return { // pick up all imports at body entry time, to properly respect hoisting @@ -111,10 +161,13 @@ module.exports = { } if (!imports.size) { - context.report( - namespace, - `No exported names found in module '${declaration.source.value}'.`, - ) + context.report({ + node: namespace, + messageId: 'noNamesFound', + data: { + module: declaration.source.value, + }, + }) } }, @@ -124,58 +177,73 @@ module.exports = { if (dereference.object.type !== 'Identifier') { return } + if (!namespaces.has(dereference.object.name)) { return } + if (declaredScope(context, dereference.object.name) !== 'module') { return } + const parent = dereference!.parent + if ( - dereference.parent.type === 'AssignmentExpression' && - dereference.parent.left === dereference + parent?.type === 'AssignmentExpression' && + parent.left === dereference ) { - context.report( - dereference.parent, - `Assignment to member of namespace '${dereference.object.name}'.`, - ) + context.report({ + node: parent, + messageId: 'namespaceMember', + data: { + namespace: dereference.object.name, + }, + }) } // go deep let namespace = namespaces.get(dereference.object.name) + const namepath = [dereference.object.name] + + let deref: TSESTree.Node | undefined = dereference + // while property is namespace and parent is member expression, keep validating while ( namespace instanceof ExportMap && - dereference.type === 'MemberExpression' + deref?.type === 'MemberExpression' ) { - if (dereference.computed) { + if (deref.computed) { if (!allowComputed) { - context.report( - dereference.property, - `Unable to validate computed reference to imported namespace '${dereference.object.name}'.`, - ) + context.report({ + node: deref.property, + messageId: 'computedReference', + data: { + namespace: 'name' in deref.object && deref.object.name, + }, + }) } return } - if (!namespace.has(dereference.property.name)) { - context.report( - dereference.property, - makeMessage(dereference.property, namepath), - ) + if (!namespace.has(deref.property.name)) { + context.report(makeMessage(deref.property, namepath)) break } - const exported = namespace.get(dereference.property.name) + const exported = namespace.get<{ namespace: ExportMap }>( + deref.property.name, + ) + if (exported == null) { return } // stash and pop - namepath.push(dereference.property.name) + namepath.push(deref.property.name) namespace = exported.namespace - dereference = dereference.parent + + deref = deref.parent } }, @@ -195,8 +263,14 @@ module.exports = { return } + const initName = init.name + // DFS traverse child namespaces - function testKey(pattern, namespace, path = [init.name]) { + function testKey( + pattern: TSESTree.Node, + namespace?: ExportMap, + path: string[] = [initName], + ) { if (!(namespace instanceof ExportMap)) { return } @@ -207,6 +281,7 @@ module.exports = { for (const property of pattern.properties) { if ( + // @ts-expect-error - experimental type property.type === 'ExperimentalRestProperty' || property.type === 'RestElement' || !property.key @@ -217,25 +292,27 @@ module.exports = { if (property.key.type !== 'Identifier') { context.report({ node: property, - message: 'Only destructure top-level names.', + messageId: 'topLevelNames', }) continue } if (!namespace.has(property.key.name)) { - context.report({ - node: property, - message: makeMessage(property.key, path), - }) + context.report(makeMessage(property.key, path, property)) continue } path.push(property.key.name) - const dependencyExportMap = namespace.get(property.key.name) + + const dependencyExportMap = namespace.get<{ namespace: ExportMap }>( + property.key.name, + ) + // could be null when ignored or ambiguous - if (dependencyExportMap !== null) { + if (dependencyExportMap != null) { testKey(property.value, dependencyExportMap.namespace, path) } + path.pop() } } @@ -244,17 +321,20 @@ module.exports = { }, JSXMemberExpression({ object, property }) { - if (!namespaces.has(object.name)) { + if ( + !('name' in object) || + typeof object.name !== 'string' || + !namespaces.has(object.name) + ) { return } - const namespace = namespaces.get(object.name) + + const namespace = namespaces.get(object.name)! + if (!namespace.has(property.name)) { - context.report({ - node: property, - message: makeMessage(property, [object.name]), - }) + context.report(makeMessage(property, [object.name])) } }, } }, -} +}) diff --git a/src/rules/no-internal-modules.js b/src/rules/no-internal-modules.ts similarity index 64% rename from src/rules/no-internal-modules.js rename to src/rules/no-internal-modules.ts index f02c2d09b..67d54610a 100644 --- a/src/rules/no-internal-modules.js +++ b/src/rules/no-internal-modules.ts @@ -3,17 +3,49 @@ import { makeRe } from 'minimatch' import { resolve } from '../utils/resolve' import { importType } from '../core/import-type' import { moduleVisitor } from '../utils/module-visitor' -import { docsUrl } from '../docs-url' +import { createRule } from '../utils' -module.exports = { +// minimatch patterns are expected to use / path separators, like import +// statements, so normalize paths to use the same +function normalizeSep(somePath: string) { + return somePath.split('\\').join('/') +} + +function toSteps(somePath: string) { + return normalizeSep(somePath) + .split('/') + .filter(step => step && step !== '.') + .reduce((acc, step) => { + if (step === '..') { + return acc.slice(0, -1) + } + return acc.concat(step) + }, []) +} + +const potentialViolationTypes = [ + 'parent', + 'index', + 'sibling', + 'external', + 'internal', +] + +type Options = { + allow?: string[] + forbid?: string[] +} + +type MessageId = 'noAllowed' + +export = createRule<[Options?], MessageId>({ + name: 'no-internal-modules', meta: { type: 'suggestion', docs: { category: 'Static analysis', description: 'Forbid importing the submodules of other modules.', - url: docsUrl('no-internal-modules'), }, - schema: [ { anyOf: [ @@ -44,42 +76,31 @@ module.exports = { ], }, ], + messages: { + noAllowed: `Reaching to "{{importPath}}" is not allowed.`, + }, }, - - create: function noReachingInside(context) { + defaultOptions: [], + create(context) { const options = context.options[0] || {} - const allowRegexps = (options.allow || []).map(p => makeRe(p)) - const forbidRegexps = (options.forbid || []).map(p => makeRe(p)) - - // minimatch patterns are expected to use / path separators, like import - // statements, so normalize paths to use the same - function normalizeSep(somePath) { - return somePath.split('\\').join('/') - } - - function toSteps(somePath) { - return normalizeSep(somePath) - .split('/') - .filter(step => step && step !== '.') - .reduce((acc, step) => { - if (step === '..') { - return acc.slice(0, -1) - } - return acc.concat(step) - }, []) - } + const allowRegexps = (options.allow || []) + .map(p => makeRe(p)) + .filter(Boolean) as RegExp[] + const forbidRegexps = (options.forbid || []) + .map(p => makeRe(p)) + .filter(Boolean) as RegExp[] // test if reaching to this destination is allowed - function reachingAllowed(importPath) { + function reachingAllowed(importPath: string) { return allowRegexps.some(re => re.test(importPath)) } // test if reaching to this destination is forbidden - function reachingForbidden(importPath) { + function reachingForbidden(importPath: string) { return forbidRegexps.some(re => re.test(importPath)) } - function isAllowViolation(importPath) { + function isAllowViolation(importPath: string) { const steps = toSteps(importPath) const nonScopeSteps = steps.filter(step => step.indexOf('@') !== 0) @@ -106,7 +127,7 @@ module.exports = { return true } - function isForbidViolation(importPath) { + function isForbidViolation(importPath: string) { const steps = toSteps(importPath) // before trying to resolve, see if the raw import (with relative @@ -133,31 +154,23 @@ module.exports = { ? isForbidViolation : isAllowViolation - function checkImportForReaching(importPath, node) { - const potentialViolationTypes = [ - 'parent', - 'index', - 'sibling', - 'external', - 'internal', - ] - if ( - potentialViolationTypes.indexOf(importType(importPath, context)) !== - -1 && - isReachViolation(importPath) - ) { - context.report({ - node, - message: `Reaching to "${importPath}" is not allowed.`, - }) - } - } - return moduleVisitor( source => { - checkImportForReaching(source.value, source) + const importPath = source.value + if ( + potentialViolationTypes.includes(importType(importPath, context)) && + isReachViolation(importPath) + ) { + context.report({ + node: source, + messageId: 'noAllowed', + data: { + importPath, + }, + }) + } }, { commonjs: true }, ) }, -} +}) diff --git a/src/rules/no-mutable-exports.js b/src/rules/no-mutable-exports.js deleted file mode 100644 index 017ec4d1f..000000000 --- a/src/rules/no-mutable-exports.js +++ /dev/null @@ -1,62 +0,0 @@ -import { docsUrl } from '../docs-url' - -module.exports = { - meta: { - type: 'suggestion', - docs: { - category: 'Helpful warnings', - description: 'Forbid the use of mutable exports with `var` or `let`.', - url: docsUrl('no-mutable-exports'), - }, - schema: [], - }, - - create(context) { - function checkDeclaration(node) { - const { kind } = node - if (kind === 'var' || kind === 'let') { - context.report( - node, - `Exporting mutable '${kind}' binding, use 'const' instead.`, - ) - } - } - - function checkDeclarationsInScope({ variables }, name) { - for (const variable of variables) { - if (variable.name === name) { - for (const def of variable.defs) { - if (def.type === 'Variable' && def.parent) { - checkDeclaration(def.parent) - } - } - } - } - } - - function handleExportDefault(node) { - const scope = context.getScope() - - if (node.declaration.name) { - checkDeclarationsInScope(scope, node.declaration.name) - } - } - - function handleExportNamed(node) { - const scope = context.getScope() - - if (node.declaration) { - checkDeclaration(node.declaration) - } else if (!node.source) { - for (const specifier of node.specifiers) { - checkDeclarationsInScope(scope, specifier.local.name) - } - } - } - - return { - ExportDefaultDeclaration: handleExportDefault, - ExportNamedDeclaration: handleExportNamed, - } - }, -} diff --git a/src/rules/no-mutable-exports.ts b/src/rules/no-mutable-exports.ts new file mode 100644 index 000000000..83b36e71e --- /dev/null +++ b/src/rules/no-mutable-exports.ts @@ -0,0 +1,70 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' + +import { createRule } from '../utils' + +type MessageId = 'noMutable' + +export = createRule<[], MessageId>({ + name: 'no-mutable-exports', + meta: { + type: 'suggestion', + docs: { + category: 'Helpful warnings', + description: 'Forbid the use of mutable exports with `var` or `let`.', + }, + schema: [], + messages: { + noMutable: "Exporting mutable '{{kind}}' binding, use 'const' instead.", + }, + }, + defaultOptions: [], + create(context) { + function checkDeclaration(node: TSESTree.NamedExportDeclarations) { + if ('kind' in node && (node.kind === 'var' || node.kind === 'let')) { + context.report({ + node, + messageId: 'noMutable', + data: { + kind: node.kind, + }, + }) + } + } + + function checkDeclarationsInScope( + { variables }: TSESLint.Scope.Scope, + name: string, + ) { + for (const variable of variables) { + if (variable.name === name) { + for (const def of variable.defs) { + if (def.type === 'Variable' && def.parent) { + checkDeclaration(def.parent) + } + } + } + } + } + + return { + ExportDefaultDeclaration(node) { + const scope = context.getScope() + + if ('name' in node.declaration) { + checkDeclarationsInScope(scope, node.declaration.name) + } + }, + ExportNamedDeclaration(node) { + const scope = context.getScope() + + if (node.declaration) { + checkDeclaration(node.declaration) + } else if (!node.source) { + for (const specifier of node.specifiers) { + checkDeclarationsInScope(scope, specifier.local.name) + } + } + }, + } + }, +}) diff --git a/src/rules/no-namespace.js b/src/rules/no-namespace.js deleted file mode 100644 index f20d41e2f..000000000 --- a/src/rules/no-namespace.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @fileoverview Rule to disallow namespace import - * @author Radek Benkel - */ - -import { minimatch } from 'minimatch' -import { docsUrl } from '../docs-url' - -//------------------------------------------------------------------------------ -// Rule Definition -//------------------------------------------------------------------------------ - -module.exports = { - meta: { - type: 'suggestion', - docs: { - category: 'Style guide', - description: 'Forbid namespace (a.k.a. "wildcard" `*`) imports.', - url: docsUrl('no-namespace'), - }, - fixable: 'code', - schema: [ - { - type: 'object', - properties: { - ignore: { - type: 'array', - items: { - type: 'string', - }, - uniqueItems: true, - }, - }, - }, - ], - }, - - create(context) { - const firstOption = context.options[0] || {} - const ignoreGlobs = firstOption.ignore - - return { - ImportNamespaceSpecifier(node) { - if ( - ignoreGlobs && - ignoreGlobs.find(glob => - minimatch(node.parent.source.value, glob, { matchBase: true }), - ) - ) { - return - } - - const scopeVariables = context.getScope().variables - const namespaceVariable = scopeVariables.find( - variable => variable.defs[0].node === node, - ) - const namespaceReferences = namespaceVariable.references - const namespaceIdentifiers = namespaceReferences.map( - reference => reference.identifier, - ) - const canFix = - namespaceIdentifiers.length > 0 && - !usesNamespaceAsObject(namespaceIdentifiers) - - context.report({ - node, - message: `Unexpected namespace import.`, - fix: - canFix && - (fixer => { - const scopeManager = context.getSourceCode().scopeManager - const fixes = [] - - // Pass 1: Collect variable names that are already in scope for each reference we want - // to transform, so that we can be sure that we choose non-conflicting import names - const importNameConflicts = {} - namespaceIdentifiers.forEach(identifier => { - const parent = identifier.parent - if (parent && parent.type === 'MemberExpression') { - const importName = getMemberPropertyName(parent) - const localConflicts = getVariableNamesInScope( - scopeManager, - parent, - ) - if (!importNameConflicts[importName]) { - importNameConflicts[importName] = localConflicts - } else { - localConflicts.forEach(c => - importNameConflicts[importName].add(c), - ) - } - } - }) - - // Choose new names for each import - const importNames = Object.keys(importNameConflicts) - const importLocalNames = generateLocalNames( - importNames, - importNameConflicts, - namespaceVariable.name, - ) - - // Replace the ImportNamespaceSpecifier with a list of ImportSpecifiers - const namedImportSpecifiers = importNames.map(importName => - importName === importLocalNames[importName] - ? importName - : `${importName} as ${importLocalNames[importName]}`, - ) - fixes.push( - fixer.replaceText( - node, - `{ ${namedImportSpecifiers.join(', ')} }`, - ), - ) - - // Pass 2: Replace references to the namespace with references to the named imports - namespaceIdentifiers.forEach(identifier => { - const parent = identifier.parent - if (parent && parent.type === 'MemberExpression') { - const importName = getMemberPropertyName(parent) - fixes.push( - fixer.replaceText(parent, importLocalNames[importName]), - ) - } - }) - - return fixes - }), - }) - }, - } - }, -} - -/** - * @param {Identifier[]} namespaceIdentifiers - * @returns {boolean} `true` if the namespace variable is more than just a glorified constant - */ -function usesNamespaceAsObject(namespaceIdentifiers) { - return !namespaceIdentifiers.every(identifier => { - const parent = identifier.parent - - // `namespace.x` or `namespace['x']` - return ( - parent && - parent.type === 'MemberExpression' && - (parent.property.type === 'Identifier' || - parent.property.type === 'Literal') - ) - }) -} - -/** - * @param {MemberExpression} memberExpression - * @returns {string} the name of the member in the object expression, e.g. the `x` in `namespace.x` - */ -function getMemberPropertyName(memberExpression) { - return memberExpression.property.type === 'Identifier' - ? memberExpression.property.name - : memberExpression.property.value -} - -/** - * @param {ScopeManager} scopeManager - * @param {ASTNode} node - * @return {Set} - */ -function getVariableNamesInScope(scopeManager, node) { - let currentNode = node - let scope = scopeManager.acquire(currentNode) - while (scope == null) { - currentNode = currentNode.parent - scope = scopeManager.acquire(currentNode, true) - } - return new Set( - scope.variables - .concat(scope.upper.variables) - .map(variable => variable.name), - ) -} - -/** - * - * @param {*} names - * @param {*} nameConflicts - * @param {*} namespaceName - */ -function generateLocalNames(names, nameConflicts, namespaceName) { - const localNames = {} - names.forEach(name => { - let localName - if (!nameConflicts[name].has(name)) { - localName = name - } else if (!nameConflicts[name].has(`${namespaceName}_${name}`)) { - localName = `${namespaceName}_${name}` - } else { - for (let i = 1; i < Infinity; i++) { - if (!nameConflicts[name].has(`${namespaceName}_${name}_${i}`)) { - localName = `${namespaceName}_${name}_${i}` - break - } - } - } - localNames[name] = localName - }) - return localNames -} diff --git a/src/rules/no-namespace.ts b/src/rules/no-namespace.ts new file mode 100644 index 000000000..c7606e506 --- /dev/null +++ b/src/rules/no-namespace.ts @@ -0,0 +1,206 @@ +/** + * Rule to disallow namespace import + */ + +import { TSESLint, TSESTree } from '@typescript-eslint/utils' +import { minimatch } from 'minimatch' + +import { createRule } from '../utils' + +type MessageId = 'noNamespace' + +type Options = { + ignore?: string[] +} + +export = createRule<[Options?], MessageId>({ + name: 'no-namespace', + meta: { + type: 'suggestion', + docs: { + category: 'Style guide', + description: 'Forbid namespace (a.k.a. "wildcard" `*`) imports.', + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + ignore: { + type: 'array', + items: { + type: 'string', + }, + uniqueItems: true, + }, + }, + }, + ], + messages: { + noNamespace: 'Unexpected namespace import.', + }, + }, + defaultOptions: [], + create(context) { + const firstOption = context.options[0] || {} + const ignoreGlobs = firstOption.ignore + + return { + ImportNamespaceSpecifier(node) { + if ( + ignoreGlobs?.find(glob => + minimatch( + (node.parent as TSESTree.ImportDeclaration).source.value, + glob, + { matchBase: true }, + ), + ) + ) { + return + } + + const scopeVariables = context.getScope().variables + const namespaceVariable = scopeVariables.find( + variable => variable.defs[0].node === node, + )! + const namespaceReferences = namespaceVariable.references + const namespaceIdentifiers = namespaceReferences.map( + reference => reference.identifier, + ) + const canFix = + namespaceIdentifiers.length > 0 && + !usesNamespaceAsObject(namespaceIdentifiers) + + context.report({ + node, + messageId: `noNamespace`, + fix: canFix + ? fixer => { + const scopeManager = context.getSourceCode().scopeManager! + const fixes: TSESLint.RuleFix[] = [] + + // Pass 1: Collect variable names that are already in scope for each reference we want + // to transform, so that we can be sure that we choose non-conflicting import names + const importNameConflicts: Record> = {} + namespaceIdentifiers.forEach(identifier => { + const parent = identifier.parent + if (parent && parent.type === 'MemberExpression') { + const importName = getMemberPropertyName(parent) + const localConflicts = getVariableNamesInScope( + scopeManager, + parent, + ) + if (!importNameConflicts[importName]) { + importNameConflicts[importName] = localConflicts + } else { + localConflicts.forEach(c => + importNameConflicts[importName].add(c), + ) + } + } + }) + + // Choose new names for each import + const importNames = Object.keys(importNameConflicts) + const importLocalNames = generateLocalNames( + importNames, + importNameConflicts, + namespaceVariable.name, + ) + + // Replace the ImportNamespaceSpecifier with a list of ImportSpecifiers + const namedImportSpecifiers = importNames.map(importName => + importName === importLocalNames[importName] + ? importName + : `${importName} as ${importLocalNames[importName]}`, + ) + fixes.push( + fixer.replaceText( + node, + `{ ${namedImportSpecifiers.join(', ')} }`, + ), + ) + + // Pass 2: Replace references to the namespace with references to the named imports + namespaceIdentifiers.forEach(identifier => { + const parent = identifier.parent + if (parent && parent.type === 'MemberExpression') { + const importName = getMemberPropertyName(parent) + fixes.push( + fixer.replaceText(parent, importLocalNames[importName]), + ) + } + }) + + return fixes + } + : null, + }) + }, + } + }, +}) + +function usesNamespaceAsObject( + namespaceIdentifiers: Array, +) { + return !namespaceIdentifiers.every(identifier => { + const parent = identifier.parent + + // `namespace.x` or `namespace['x']` + return ( + parent && + parent.type === 'MemberExpression' && + (parent.property.type === 'Identifier' || + parent.property.type === 'Literal') + ) + }) +} + +function getMemberPropertyName(memberExpression: TSESTree.MemberExpression) { + return memberExpression.property.type === 'Identifier' + ? memberExpression.property.name + : (memberExpression.property as TSESTree.StringLiteral).value +} + +function getVariableNamesInScope( + scopeManager: TSESLint.Scope.ScopeManager, + node: TSESTree.Node, +) { + let currentNode: TSESTree.Node = node + let scope = scopeManager.acquire(currentNode) + while (scope == null) { + currentNode = currentNode.parent! + scope = scopeManager.acquire(currentNode, true) + } + return new Set( + scope.variables + .concat(scope.upper!.variables) + .map(variable => variable.name), + ) +} + +function generateLocalNames( + names: string[], + nameConflicts: Record>, + namespaceName: string, +) { + const localNames: Record = {} + names.forEach(name => { + let localName: string + if (!nameConflicts[name].has(name)) { + localName = name + } else if (!nameConflicts[name].has(`${namespaceName}_${name}`)) { + localName = `${namespaceName}_${name}` + } else { + for (let i = 1; i < Infinity; i++) { + if (!nameConflicts[name].has(`${namespaceName}_${name}_${i}`)) { + localName = `${namespaceName}_${name}_${i}` + break + } + } + } + localNames[name] = localName! + }) + return localNames +} diff --git a/src/rules/no-relative-packages.js b/src/rules/no-relative-packages.ts similarity index 66% rename from src/rules/no-relative-packages.js rename to src/rules/no-relative-packages.ts index 09f76149b..288e754c2 100644 --- a/src/rules/no-relative-packages.js +++ b/src/rules/no-relative-packages.ts @@ -1,17 +1,23 @@ import path from 'path' -import { readPkgUp } from '../utils/read-pkg-ip' +import { TSESTree } from '@typescript-eslint/utils' + +import { readPkgUp } from '../utils/read-pkg-ip' import { resolve } from '../utils/resolve' -import { moduleVisitor, makeOptionsSchema } from '../utils/module-visitor' +import { + moduleVisitor, + makeOptionsSchema, + ModuleOptions, +} from '../utils/module-visitor' import { importType } from '../core/import-type' -import { docsUrl } from '../docs-url' +import { createRule } from '../utils' +import { RuleContext } from '../types' -/** @param {string} filePath */ -function toPosixPath(filePath) { +function toPosixPath(filePath: string) { return filePath.replace(/\\/g, '/') } -function findNamedPackage(filePath) { +function findNamedPackage(filePath: string) { const found = readPkgUp({ cwd: filePath }) if (found.pkg && !found.pkg.name) { return findNamedPackage(path.join(found.path, '../..')) @@ -19,8 +25,15 @@ function findNamedPackage(filePath) { return found } -function checkImportForRelativePackage(context, importPath, node) { - const potentialViolationTypes = ['parent', 'index', 'sibling'] +type MessageId = 'noAllowed' + +const potentialViolationTypes = ['parent', 'index', 'sibling'] + +function checkImportForRelativePackage( + context: RuleContext, + importPath: string, + node: TSESTree.StringLiteral, +) { if (potentialViolationTypes.indexOf(importType(importPath, context)) === -1) { return } @@ -52,29 +65,37 @@ function checkImportForRelativePackage(context, importPath, node) { ) context.report({ node, - message: `Relative import from another package is not allowed. Use \`${properImport}\` instead of \`${importPath}\``, + messageId: 'noAllowed', + data: { + properImport, + importPath, + }, fix: fixer => fixer.replaceText(node, JSON.stringify(toPosixPath(properImport))), }) } } -module.exports = { +export = createRule<[ModuleOptions?], MessageId>({ + name: 'no-relative-packages', meta: { type: 'suggestion', docs: { category: 'Static analysis', description: 'Forbid importing packages through relative paths.', - url: docsUrl('no-relative-packages'), }, fixable: 'code', schema: [makeOptionsSchema()], + messages: { + noAllowed: + 'Relative import from another package is not allowed. Use `{{properImport}}` instead of `{{importPath}}`', + }, }, - + defaultOptions: [], create(context) { return moduleVisitor( source => checkImportForRelativePackage(context, source.value, source), context.options[0], ) }, -} +}) diff --git a/src/rules/no-relative-parent-imports.js b/src/rules/no-relative-parent-imports.js deleted file mode 100644 index ac8fa3f2d..000000000 --- a/src/rules/no-relative-parent-imports.js +++ /dev/null @@ -1,54 +0,0 @@ -import { moduleVisitor, makeOptionsSchema } from '../utils/module-visitor' -import { docsUrl } from '../docs-url' -import { basename, dirname, relative } from 'path' -import { resolve } from '../utils/resolve' - -import { importType } from '../core/import-type' - -module.exports = { - meta: { - type: 'suggestion', - docs: { - category: 'Static analysis', - description: 'Forbid importing modules from parent directories.', - url: docsUrl('no-relative-parent-imports'), - }, - schema: [makeOptionsSchema()], - }, - - create: function noRelativePackages(context) { - const myPath = context.getPhysicalFilename - ? context.getPhysicalFilename() - : context.getFilename() - if (myPath === '') { - return {} - } // can't check a non-file - - function checkSourceValue(sourceNode) { - const depPath = sourceNode.value - - if (importType(depPath, context) === 'external') { - // ignore packages - return - } - - const absDepPath = resolve(depPath, context) - - if (!absDepPath) { - // unable to resolve path - return - } - - const relDepPath = relative(dirname(myPath), absDepPath) - - if (importType(relDepPath, context) === 'parent') { - context.report({ - node: sourceNode, - message: `Relative imports from parent directories are not allowed. Please either pass what you're importing through at runtime (dependency injection), move \`${basename(myPath)}\` to same directory as \`${depPath}\` or consider making \`${depPath}\` a package.`, - }) - } - } - - return moduleVisitor(checkSourceValue, context.options[0]) - }, -} diff --git a/src/rules/no-relative-parent-imports.ts b/src/rules/no-relative-parent-imports.ts new file mode 100644 index 000000000..d15cb5818 --- /dev/null +++ b/src/rules/no-relative-parent-imports.ts @@ -0,0 +1,67 @@ +import { basename, dirname, relative } from 'path' + +import { + moduleVisitor, + makeOptionsSchema, + ModuleOptions, +} from '../utils/module-visitor' +import { resolve } from '../utils/resolve' +import { importType } from '../core/import-type' +import { createRule } from '../utils' + +type MessageId = 'noAllowed' + +export = createRule<[ModuleOptions?], MessageId>({ + name: 'no-relative-parent-imports', + meta: { + type: 'suggestion', + docs: { + category: 'Static analysis', + description: 'Forbid importing modules from parent directories.', + }, + schema: [makeOptionsSchema()], + messages: { + noAllowed: + "Relative imports from parent directories are not allowed. Please either pass what you're importing through at runtime (dependency injection), move `{{filename}}` to same directory as `{{depPath}}` or consider making `{{depPath}}` a package.", + }, + }, + defaultOptions: [], + create(context) { + const filename = context.getPhysicalFilename + ? context.getPhysicalFilename() + : context.getFilename() + + if (filename === '') { + return {} + } // can't check a non-file + + return moduleVisitor(sourceNode => { + const depPath = sourceNode.value + + if (importType(depPath, context) === 'external') { + // ignore packages + return + } + + const absDepPath = resolve(depPath, context) + + if (!absDepPath) { + // unable to resolve path + return + } + + const relDepPath = relative(dirname(filename), absDepPath) + + if (importType(relDepPath, context) === 'parent') { + context.report({ + node: sourceNode, + messageId: 'noAllowed', + data: { + filename: basename(filename), + depPath, + }, + }) + } + }, context.options[0]) + }, +}) diff --git a/src/rules/no-restricted-paths.js b/src/rules/no-restricted-paths.ts similarity index 56% rename from src/rules/no-restricted-paths.js rename to src/rules/no-restricted-paths.ts index f7377e8f2..875ba9b99 100644 --- a/src/rules/no-restricted-paths.js +++ b/src/rules/no-restricted-paths.ts @@ -1,26 +1,47 @@ import path from 'path' -import { resolve } from '../utils/resolve' -import { moduleVisitor } from '../utils/module-visitor' import isGlob from 'is-glob' import { Minimatch } from 'minimatch' -import { docsUrl } from '../docs-url' + +import { resolve } from '../utils/resolve' +import { moduleVisitor } from '../utils/module-visitor' import { importType } from '../core/import-type' +import { createRule } from '../utils' +import { Arrayable } from '../types' +import { TSESTree } from '@typescript-eslint/utils' -const containsPath = (filepath, target) => { +const containsPath = (filepath: string, target: string) => { const relative = path.relative(target, filepath) return relative === '' || !relative.startsWith('..') } -module.exports = { +type Options = { + basePath?: string + zones?: Array<{ + from: Arrayable + target: Arrayable + message?: string + except?: string[] + }> +} + +type MessageId = 'path' | 'mixedGlob' | 'glob' | 'zone' + +type Validator = { + isPathRestricted: (absoluteImportPath: string) => boolean + hasValidExceptions: boolean + isPathException?: (absoluteImportPath: string) => boolean + reportInvalidException: (node: TSESTree.Node) => void +} + +export = createRule<[Options?], MessageId>({ + name: 'no-restricted-paths', meta: { type: 'problem', docs: { category: 'Static analysis', description: 'Enforce which files can be imported in a given folder.', - url: docsUrl('no-restricted-paths'), }, - schema: [ { type: 'object', @@ -70,8 +91,15 @@ module.exports = { additionalProperties: false, }, ], + messages: { + path: 'Restricted path exceptions must be descendants of the configured `from` path for that zone.', + mixedGlob: + 'Restricted path `from` must contain either only glob patterns or none', + glob: 'Restricted path exceptions must be glob patterns when `from` contains glob patterns', + zone: 'Unexpected path "{{importPath}}" imported in restricted zone.{{extra}}', + }, }, - + defaultOptions: [], create: function noRestrictedPaths(context) { const options = context.options[0] || {} const restrictedPaths = options.zones || [] @@ -80,13 +108,13 @@ module.exports = { ? context.getPhysicalFilename() : context.getFilename() const matchingZones = restrictedPaths.filter(zone => - [] - .concat(zone.target) + [zone.target] + .flat() .map(target => path.resolve(basePath, target)) .some(targetPath => isMatchingTargetPath(currentFilename, targetPath)), ) - function isMatchingTargetPath(filename, targetPath) { + function isMatchingTargetPath(filename: string, targetPath: string) { if (isGlob(targetPath)) { const mm = new Minimatch(targetPath) return mm.match(filename) @@ -95,7 +123,10 @@ module.exports = { return containsPath(filename, targetPath) } - function isValidExceptionPath(absoluteFromPath, absoluteExceptionPath) { + function isValidExceptionPath( + absoluteFromPath: string, + absoluteExceptionPath: string, + ) { const relativeExceptionPath = path.relative( absoluteFromPath, absoluteExceptionPath, @@ -104,34 +135,31 @@ module.exports = { return importType(relativeExceptionPath, context) !== 'parent' } - function areBothGlobPatternAndAbsolutePath(areGlobPatterns) { + function areBothGlobPatternAndAbsolutePath(areGlobPatterns: boolean[]) { return ( areGlobPatterns.some(isGlob => isGlob) && areGlobPatterns.some(isGlob => !isGlob) ) } - function reportInvalidExceptionPath(node) { + function reportInvalidExceptionPath(node: TSESTree.Node) { context.report({ node, - message: - 'Restricted path exceptions must be descendants of the configured `from` path for that zone.', + messageId: 'path', }) } - function reportInvalidExceptionMixedGlobAndNonGlob(node) { + function reportInvalidExceptionMixedGlobAndNonGlob(node: TSESTree.Node) { context.report({ node, - message: - 'Restricted path `from` must contain either only glob patterns or none', + messageId: 'mixedGlob', }) } - function reportInvalidExceptionGlob(node) { + function reportInvalidExceptionGlob(node: TSESTree.Node) { context.report({ node, - message: - 'Restricted path exceptions must be glob patterns when `from` contains glob patterns', + messageId: 'glob', }) } @@ -143,17 +171,20 @@ module.exports = { } } - function computeGlobPatternPathValidator(absoluteFrom, zoneExcept) { - let isPathException + function computeGlobPatternPathValidator( + absoluteFrom: string, + zoneExcept: string[], + ) { + let isPathException: ((absoluteImportPath: string) => boolean) | undefined const mm = new Minimatch(absoluteFrom) - const isPathRestricted = absoluteImportPath => + const isPathRestricted = (absoluteImportPath: string) => mm.match(absoluteImportPath) - const hasValidExceptions = zoneExcept.every(isGlob) + const hasValidExceptions = zoneExcept.every(it => isGlob(it)) if (hasValidExceptions) { const exceptionsMm = zoneExcept.map(except => new Minimatch(except)) - isPathException = absoluteImportPath => + isPathException = (absoluteImportPath: string) => exceptionsMm.some(mm => mm.match(absoluteImportPath)) } @@ -167,10 +198,13 @@ module.exports = { } } - function computeAbsolutePathValidator(absoluteFrom, zoneExcept) { - let isPathException + function computeAbsolutePathValidator( + absoluteFrom: string, + zoneExcept: string[], + ) { + let isPathException: ((absoluteImportPath: string) => boolean) | undefined - const isPathRestricted = absoluteImportPath => + const isPathRestricted = (absoluteImportPath: string) => containsPath(absoluteImportPath, absoluteFrom) const absoluteExceptionPaths = zoneExcept.map(exceptionPath => @@ -198,28 +232,37 @@ module.exports = { } } - function reportInvalidExceptions(validators, node) { + function reportInvalidExceptions( + validators: Validator[], + node: TSESTree.Node, + ) { validators.forEach(validator => validator.reportInvalidException(node)) } function reportImportsInRestrictedZone( - validators, - node, - importPath, - customMessage, + validators: Validator[], + node: TSESTree.Node, + importPath: string, + customMessage?: string, ) { validators.forEach(() => { context.report({ node, - message: `Unexpected path "{{importPath}}" imported in restricted zone.${customMessage ? ` ${customMessage}` : ''}`, - data: { importPath }, + messageId: 'zone', + data: { + importPath, + extra: customMessage ? ` ${customMessage}` : '', + }, }) }) } - const makePathValidators = (zoneFrom, zoneExcept = []) => { - const allZoneFrom = [].concat(zoneFrom) - const areGlobPatterns = allZoneFrom.map(isGlob) + const makePathValidators = ( + zoneFrom: Arrayable, + zoneExcept: string[] = [], + ) => { + const allZoneFrom = [zoneFrom].flat() + const areGlobPatterns = allZoneFrom.map(it => isGlob(it)) if (areBothGlobPatternAndAbsolutePath(areGlobPatterns)) { return [computeMixedGlobAndAbsolutePathValidator()] @@ -237,50 +280,49 @@ module.exports = { }) } - const validators = [] + const validators: Validator[][] = [] - function checkForRestrictedImportPath(importPath, node) { - const absoluteImportPath = resolve(importPath, context) + return moduleVisitor( + source => { + const importPath = source.value - if (!absoluteImportPath) { - return - } + const absoluteImportPath = resolve(importPath, context) - matchingZones.forEach((zone, index) => { - if (!validators[index]) { - validators[index] = makePathValidators(zone.from, zone.except) + if (!absoluteImportPath) { + return } - const applicableValidatorsForImportPath = validators[index].filter( - validator => validator.isPathRestricted(absoluteImportPath), - ) + matchingZones.forEach((zone, index) => { + if (!validators[index]) { + validators[index] = makePathValidators(zone.from, zone.except) + } - const validatorsWithInvalidExceptions = - applicableValidatorsForImportPath.filter( - validator => !validator.hasValidExceptions, + const applicableValidatorsForImportPath = validators[index].filter( + validator => validator.isPathRestricted(absoluteImportPath), ) - reportInvalidExceptions(validatorsWithInvalidExceptions, node) - const applicableValidatorsForImportPathExcludingExceptions = - applicableValidatorsForImportPath.filter( - validator => - validator.hasValidExceptions && - !validator.isPathException(absoluteImportPath), + const validatorsWithInvalidExceptions = + applicableValidatorsForImportPath.filter( + validator => !validator.hasValidExceptions, + ) + + reportInvalidExceptions(validatorsWithInvalidExceptions, source) + + const applicableValidatorsForImportPathExcludingExceptions = + applicableValidatorsForImportPath.filter( + validator => + validator.hasValidExceptions && + !validator.isPathException!(absoluteImportPath), + ) + reportImportsInRestrictedZone( + applicableValidatorsForImportPathExcludingExceptions, + source, + importPath, + zone.message, ) - reportImportsInRestrictedZone( - applicableValidatorsForImportPathExcludingExceptions, - node, - importPath, - zone.message, - ) - }) - } - - return moduleVisitor( - source => { - checkForRestrictedImportPath(source.value, source) + }) }, { commonjs: true }, ) }, -} +}) diff --git a/src/rules/no-unresolved.ts b/src/rules/no-unresolved.ts index 7d3db0c26..13f930fa1 100644 --- a/src/rules/no-unresolved.ts +++ b/src/rules/no-unresolved.ts @@ -15,16 +15,14 @@ import { } from '../utils/module-visitor' import { createRule } from '../utils' -type Options = [ - ModuleOptions & { - caseSensitive?: boolean - caseSensitiveStrict?: boolean - }, -] +type Options = ModuleOptions & { + caseSensitive?: boolean + caseSensitiveStrict?: boolean +} type MessageId = 'unresolved' | 'casingMismatch' -export = createRule({ +export = createRule<[Options?], MessageId>({ name: 'no-unresolved', meta: { type: 'problem', @@ -32,7 +30,6 @@ export = createRule({ category: 'Static analysis', description: 'Ensure imports point to a file/module that can be resolved.', - recommended: 'error', }, messages: { unresolved: "Unable to resolve path to module '{{module}}'.", @@ -46,11 +43,7 @@ export = createRule({ }), ], }, - defaultOptions: [ - { - caseSensitive: true, - }, - ], + defaultOptions: [], create(context) { const options = context.options[0] || {} diff --git a/src/utils/module-visitor.ts b/src/utils/module-visitor.ts index 8f1f77dbb..638d7ecd3 100644 --- a/src/utils/module-visitor.ts +++ b/src/utils/module-visitor.ts @@ -24,10 +24,10 @@ export interface ModuleOptions { * Returns an object of node visitors that will call * 'visitor' with every discovered module path. */ -export function moduleVisitor(visitor: Visitor, options: ModuleOptions) { - const ignore = options && options.ignore - const amd = !!(options && options.amd) - const commonjs = !!(options && options.commonjs) +export function moduleVisitor(visitor: Visitor, options?: ModuleOptions) { + const ignore = options?.ignore + const amd = !!options?.amd + const commonjs = !!options?.commonjs // if esmodule is not explicitly disabled, it is assumed to be enabled const esmodule = !!{ esmodule: true, ...options }.esmodule @@ -45,7 +45,7 @@ export function moduleVisitor(visitor: Visitor, options: ModuleOptions) { ) { if (source == null) { return - } //? + } // handle ignore if (ignoreRegExps.some(re => re.test(String(source.value)))) { diff --git a/src/utils/read-pkg-ip.ts b/src/utils/read-pkg-ip.ts index ec3fb575e..0cb24bed2 100644 --- a/src/utils/read-pkg-ip.ts +++ b/src/utils/read-pkg-ip.ts @@ -19,7 +19,9 @@ export function readPkgUp(opts?: { cwd?: string }) { return { pkg: JSON.parse( stripBOM(fs.readFileSync(fp, { encoding: 'utf-8' })), - ) as PackageJson, + ) as PackageJson & { + name: string + }, path: fp, } } catch (e) { diff --git a/test/rules/consistent-type-specifier-style.spec.js b/test/rules/consistent-type-specifier-style.spec.ts similarity index 68% rename from test/rules/consistent-type-specifier-style.spec.js rename to test/rules/consistent-type-specifier-style.spec.ts index 40add6e6b..4db272b8c 100644 --- a/test/rules/consistent-type-specifier-style.spec.js +++ b/test/rules/consistent-type-specifier-style.spec.ts @@ -1,13 +1,8 @@ -import { RuleTester } from 'eslint' -import { - test, - parsers, - tsVersionSatisfies, - eslintVersionSatisfies, - typescriptEslintParserSatisfies, -} from '../utils' +import { TSESLint, TSESTree } from '@typescript-eslint/utils' + +import rule from '../../src/rules/consistent-type-specifier-style' -const rule = require('rules/consistent-type-specifier-style') +import { test, parsers } from '../utils' const COMMON_TESTS = { valid: [ @@ -117,9 +112,11 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + data: { + kind: 'type', + }, + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -129,9 +126,8 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -141,9 +137,8 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -153,9 +148,8 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -165,9 +159,8 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -177,9 +170,8 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -189,9 +181,8 @@ const COMMON_TESTS = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -219,9 +210,8 @@ import { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -235,9 +225,11 @@ import { options: ['prefer-inline'], errors: [ { - message: - 'Prefer using inline type specifiers instead of a top-level type-only import.', - type: 'ImportDeclaration', + messageId: 'inline', + data: { + kind: 'type', + }, + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -247,14 +239,13 @@ import { options: ['prefer-inline'], errors: [ { - message: - 'Prefer using inline type specifiers instead of a top-level type-only import.', - type: 'ImportDeclaration', + messageId: 'inline', + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, ], -} +} as const const TS_ONLY = { valid: [ @@ -310,9 +301,11 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + data: { + kind: 'typeof', + }, + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -322,9 +315,8 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -334,9 +326,11 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type/typeof-only import instead of inline type/typeof specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + data: { + kind: 'type/typeof', + }, + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -346,9 +340,8 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportDeclaration', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -358,9 +351,8 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -370,9 +362,8 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -383,14 +374,18 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level type-only import instead of inline type specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + data: { + kind: 'type', + }, + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + data: { + kind: 'typeof', + }, + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -400,9 +395,8 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -413,9 +407,8 @@ const FLOW_ONLY = { options: ['prefer-top-level'], errors: [ { - message: - 'Prefer using a top-level typeof-only import instead of inline typeof specifiers.', - type: 'ImportSpecifier', + messageId: 'topLevel', + type: TSESTree.AST_NODE_TYPES.ImportSpecifier, }, ], }, @@ -429,9 +422,11 @@ const FLOW_ONLY = { options: ['prefer-inline'], errors: [ { - message: - 'Prefer using inline typeof specifiers instead of a top-level typeof-only import.', - type: 'ImportDeclaration', + messageId: 'inline', + data: { + kind: 'typeof', + }, + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, @@ -441,26 +436,16 @@ const FLOW_ONLY = { options: ['prefer-inline'], errors: [ { - message: - 'Prefer using inline typeof specifiers instead of a top-level typeof-only import.', - type: 'ImportDeclaration', + messageId: 'inline', + type: TSESTree.AST_NODE_TYPES.ImportDeclaration, }, ], }, ], -} +} as const describe('TypeScript', () => { - // inline type specifiers weren't supported prior to TS v4.5 - if ( - !parsers.TS || - !tsVersionSatisfies('>= 4.5') || - !typescriptEslintParserSatisfies('>= 5.7.0') - ) { - return - } - - const ruleTester = new RuleTester({ + const ruleTester = new TSESLint.RuleTester({ parser: parsers.TS, parserOptions: { ecmaVersion: 6, @@ -468,17 +453,13 @@ describe('TypeScript', () => { }, }) ruleTester.run('consistent-type-specifier-style', rule, { - valid: [].concat(COMMON_TESTS.valid, TS_ONLY.valid), - invalid: [].concat(COMMON_TESTS.invalid, TS_ONLY.invalid), + valid: [...COMMON_TESTS.valid, ...TS_ONLY.valid], + invalid: [...COMMON_TESTS.invalid, ...TS_ONLY.invalid], }) }) describe('Babel/Flow', () => { - if (!eslintVersionSatisfies('> 3')) { - return - } - - const ruleTester = new RuleTester({ + const ruleTester = new TSESLint.RuleTester({ parser: parsers.BABEL, parserOptions: { ecmaVersion: 6, @@ -486,7 +467,7 @@ describe('Babel/Flow', () => { }, }) ruleTester.run('consistent-type-specifier-style', rule, { - valid: [].concat(COMMON_TESTS.valid, FLOW_ONLY.valid), - invalid: [].concat(COMMON_TESTS.invalid, FLOW_ONLY.invalid), + valid: [...COMMON_TESTS.valid, ...FLOW_ONLY.valid], + invalid: [...COMMON_TESTS.invalid, ...FLOW_ONLY.invalid], }) }) diff --git a/test/rules/export.spec.js b/test/rules/export.spec.ts similarity index 75% rename from test/rules/export.spec.js rename to test/rules/export.spec.ts index 8069c16a1..786c721db 100644 --- a/test/rules/export.spec.js +++ b/test/rules/export.spec.ts @@ -1,3 +1,7 @@ +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/export' + import { test, testFilePath, @@ -6,15 +10,10 @@ import { testVersion, } from '../utils' -import { RuleTester } from 'eslint' -import eslintPkg from 'eslint/package.json' -import semver from 'semver' - -const ruleTester = new RuleTester() -const rule = require('rules/export') +const ruleTester = new TSESLint.RuleTester() ruleTester.run('export', rule, { - valid: [].concat( + valid: [ test({ code: 'import "./malformed.js"' }), // default @@ -31,7 +30,7 @@ ruleTester.run('export', rule, { // #328: "export * from" does not export a default test({ code: 'export default foo; export * from "./bar"' }), - SYNTAX_CASES, + ...SYNTAX_CASES, test({ code: ` @@ -41,7 +40,7 @@ ruleTester.run('export', rule, { export { A, B }; `, }), - testVersion('>= 6', () => ({ + test({ code: ` export * as A from './named-export-collision/a'; export * as B from './named-export-collision/b'; @@ -49,7 +48,7 @@ ruleTester.run('export', rule, { parserOptions: { ecmaVersion: 2020, }, - })) || [], + }), { code: ` @@ -61,9 +60,9 @@ ruleTester.run('export', rule, { `, parser: parsers.TS, }, - ), + ], - invalid: [].concat( + invalid: [ // multiple defaults // test({ // code: 'export default foo; export default bar', @@ -150,7 +149,7 @@ ruleTester.run('export', rule, { }), // es2022: Arbitrary module namespace identifier names - testVersion('>= 8.7', () => ({ + ...testVersion('>= 8.7', () => ({ code: 'let foo; export { foo as "foo" }; export * from "./export-all"', errors: [ "Multiple exports of name 'foo'.", @@ -160,7 +159,7 @@ ruleTester.run('export', rule, { ecmaVersion: 2022, }, })), - ), + ], }) describe('TypeScript', () => { @@ -175,7 +174,7 @@ describe('TypeScript', () => { } ruleTester.run('export', rule, { - valid: [].concat( + valid: [ // type/value name clash test({ code: ` @@ -253,66 +252,59 @@ describe('TypeScript', () => { `, ...parserConfig, }), - semver.satisfies(eslintPkg.version, '>= 6') - ? [ - test({ - code: ` + + test({ + code: ` export class Foo { } export namespace Foo { } export namespace Foo { export class Bar {} } `, - ...parserConfig, - }), - test({ - code: ` + ...parserConfig, + }), + test({ + code: ` export function Foo(); export namespace Foo { } `, - ...parserConfig, - }), - test({ - code: ` + ...parserConfig, + }), + test({ + code: ` export function Foo(a: string); export namespace Foo { } `, - ...parserConfig, - }), - test({ - code: ` + ...parserConfig, + }), + test({ + code: ` export function Foo(a: string); export function Foo(a: number); export namespace Foo { } `, - ...parserConfig, - }), - test({ - code: ` + ...parserConfig, + }), + test({ + code: ` export enum Foo { } export namespace Foo { } `, - ...parserConfig, - }), - ] - : [], + ...parserConfig, + }), test({ code: 'export * from "./file1.ts"', filename: testFilePath('typescript-d-ts/file-2.ts'), ...parserConfig, }), - semver.satisfies(eslintPkg.version, '>= 6') - ? [ - test({ - code: ` + test({ + code: ` export * as A from './named-export-collision/a'; export * as B from './named-export-collision/b'; `, - parser, - }), - ] - : [], + parser, + }), // Exports in ambient modules test({ @@ -351,8 +343,8 @@ describe('TypeScript', () => { 'import-x/extensions': ['.js', '.ts', '.jsx'], }, }), - ), - invalid: [].concat( + ], + invalid: [ // type/value name clash test({ code: ` @@ -446,135 +438,132 @@ describe('TypeScript', () => { ], ...parserConfig, }), - semver.satisfies(eslintPkg.version, '< 6') - ? [] - : [ - test({ - code: ` + + test({ + code: ` export class Foo { } export class Foo { } export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - test({ - code: ` + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), + test({ + code: ` export enum Foo { } export enum Foo { } export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - test({ - code: ` + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), + test({ + code: ` export enum Foo { } export class Foo { } export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - test({ - code: ` + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), + test({ + code: ` export const Foo = 'bar'; export class Foo { } export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - test({ - code: ` + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), + test({ + code: ` export function Foo(); export class Foo { } export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - test({ - code: ` + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), + test({ + code: ` export const Foo = 'bar'; export function Foo(); export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - test({ - code: ` + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), + test({ + code: ` export const Foo = 'bar'; export namespace Foo { } `, - errors: [ - { - message: `Multiple exports of name 'Foo'.`, - line: 2, - }, - { - message: `Multiple exports of name 'Foo'.`, - line: 3, - }, - ], - ...parserConfig, - }), - ], + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + ...parserConfig, + }), // Exports in ambient modules test({ @@ -600,6 +589,6 @@ describe('TypeScript', () => { ], ...parserConfig, }), - ), + ], }) }) diff --git a/test/rules/extensions.spec.js b/test/rules/extensions.spec.ts similarity index 99% rename from test/rules/extensions.spec.js rename to test/rules/extensions.spec.ts index 6b9a336f3..3bd40d511 100644 --- a/test/rules/extensions.spec.js +++ b/test/rules/extensions.spec.ts @@ -1,8 +1,10 @@ -import { RuleTester } from 'eslint' -import rule from 'rules/extensions' +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/extensions' + import { test, testFilePath, parsers } from '../utils' -const ruleTester = new RuleTester() +const ruleTester = new TSESLint.RuleTester() ruleTester.run('extensions', rule, { valid: [ diff --git a/test/rules/group-exports.spec.js b/test/rules/group-exports.spec.ts similarity index 93% rename from test/rules/group-exports.spec.js rename to test/rules/group-exports.spec.ts index 1b584e795..0df763d54 100644 --- a/test/rules/group-exports.spec.js +++ b/test/rules/group-exports.spec.ts @@ -1,25 +1,24 @@ -import { test } from '../utils' -import { RuleTester } from 'eslint' -import rule from 'rules/group-exports' -import { resolve } from 'path' -import babelPresetFlow from '@babel/preset-flow' +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/group-exports' + +import { parsers, test } from '../utils' -/* eslint-disable max-len */ const errors = { named: 'Multiple named export declarations; consolidate all named exports into a single export declaration', commonjs: 'Multiple CommonJS exports; consolidate all exports into a single assignment to `module.exports`', } -/* eslint-enable max-len */ -const ruleTester = new RuleTester({ - parser: resolve(__dirname, '../../node_modules/@babel/eslint-parser'), + +const ruleTester = new TSESLint.RuleTester({ + parser: parsers.BABEL, parserOptions: { requireConfigFile: false, babelOptions: { configFile: false, babelrc: false, - presets: [babelPresetFlow], + presets: ['@babel/preset-flow'], }, }, }) diff --git a/test/rules/namespace.spec.js b/test/rules/namespace.spec.ts similarity index 86% rename from test/rules/namespace.spec.js rename to test/rules/namespace.spec.ts index f1b3c99e2..364e0662f 100644 --- a/test/rules/namespace.spec.js +++ b/test/rules/namespace.spec.ts @@ -1,3 +1,7 @@ +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/namespace' + import { test, SYNTAX_CASES, @@ -5,12 +9,13 @@ import { testFilePath, parsers, } from '../utils' -import { RuleTester } from 'eslint' -const ruleTester = new RuleTester({ env: { es6: true } }) -const rule = require('rules/namespace') +const ruleTester = new TSESLint.RuleTester({ + parser: require.resolve('espree'), + parserOptions: { env: { es6: true } }, +}) -function error(name, namespace) { +function error(name: string, namespace: string) { return { message: `'${name}' not found in imported namespace '${namespace}'.`, } @@ -224,47 +229,45 @@ const valid = [ `, }), - ...[].concat( - testVersion('>= 6', () => ({ - code: ` - import * as middle from './middle'; + ...testVersion('>= 6', () => ({ + code: ` + import * as middle from './middle'; - console.log(middle.myName); - `, - filename: testFilePath('export-star-2/downstream.js'), - parserOptions: { - ecmaVersion: 2020, - }, - })), - // es2022: Arbitrary module namespace identifier names - testVersion('>= 8.7', () => ({ - code: "import * as names from './default-export-string';", - parserOptions: { ecmaVersion: 2022 }, - })), - testVersion('>= 8.7', () => ({ - code: "import * as names from './default-export-string'; console.log(names.default)", - parserOptions: { ecmaVersion: 2022 }, - })), - testVersion('>= 8.7', () => ({ - code: "import * as names from './default-export-namespace-string';", - parserOptions: { ecmaVersion: 2022 }, - })), - testVersion('>= 8.7', () => ({ - code: "import * as names from './default-export-namespace-string'; console.log(names.default)", - parserOptions: { ecmaVersion: 2022 }, - })), - testVersion('>= 8.7', () => ({ - code: `import { "b" as b } from "./deep/a"; console.log(b.c.d.e)`, - parserOptions: { ecmaVersion: 2022 }, - })), - testVersion('>= 8.7', () => ({ - code: `import { "b" as b } from "./deep/a"; var {c:{d:{e}}} = b`, - parserOptions: { ecmaVersion: 2022 }, - })), - ), + console.log(middle.myName); + `, + filename: testFilePath('export-star-2/downstream.js'), + parserOptions: { + ecmaVersion: 2020, + }, + })), + // es2022: Arbitrary module namespace identifier names + ...testVersion('>= 8.7', () => ({ + code: "import * as names from './default-export-string';", + parserOptions: { ecmaVersion: 2022 }, + })), + ...testVersion('>= 8.7', () => ({ + code: "import * as names from './default-export-string'; console.log(names.default)", + parserOptions: { ecmaVersion: 2022 }, + })), + ...testVersion('>= 8.7', () => ({ + code: "import * as names from './default-export-namespace-string';", + parserOptions: { ecmaVersion: 2022 }, + })), + ...testVersion('>= 8.7', () => ({ + code: "import * as names from './default-export-namespace-string'; console.log(names.default)", + parserOptions: { ecmaVersion: 2022 }, + })), + ...testVersion('>= 8.7', () => ({ + code: `import { "b" as b } from "./deep/a"; console.log(b.c.d.e)`, + parserOptions: { ecmaVersion: 2022 }, + })), + ...testVersion('>= 8.7', () => ({ + code: `import { "b" as b } from "./deep/a"; var {c:{d:{e}}} = b`, + parserOptions: { ecmaVersion: 2022 }, + })), ] -const invalid = [].concat( +const invalid = [ test({ code: "import * as names from './named-exports'; console.log(names.c)", errors: [error('c', 'names')], @@ -383,25 +386,25 @@ const invalid = [].concat( }), // es2022: Arbitrary module namespace identifier names - testVersion('>= 8.7', () => ({ + ...testVersion('>= 8.7', () => ({ code: `import { "b" as b } from "./deep/a"; console.log(b.e)`, errors: ["'e' not found in imported namespace 'b'."], parserOptions: { ecmaVersion: 2022 }, })), - testVersion('>= 8.7', () => ({ + ...testVersion('>= 8.7', () => ({ code: `import { "b" as b } from "./deep/a"; console.log(b.c.e)`, errors: ["'e' not found in deeply imported namespace 'b.c'."], parserOptions: { ecmaVersion: 2022 }, })), -) +] /////////////////////// // deep dereferences // ////////////////////// ;[ - ['deep', require.resolve('espree')], + ['deep'], // FIXME: check and enable - // ['deep-es7', parsers.BABEL] + // ['deep-es7', parsers.BABEL], ].forEach(function ([folder, parser]) { // close over params valid.push( diff --git a/test/rules/no-internal-modules.spec.js b/test/rules/no-internal-modules.spec.ts similarity index 98% rename from test/rules/no-internal-modules.spec.js rename to test/rules/no-internal-modules.spec.ts index 689f817bf..18c3490df 100644 --- a/test/rules/no-internal-modules.spec.js +++ b/test/rules/no-internal-modules.spec.ts @@ -1,9 +1,10 @@ -import { RuleTester } from 'eslint' -import rule from 'rules/no-internal-modules' +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/no-internal-modules' import { test, testFilePath, parsers } from '../utils' -const ruleTester = new RuleTester() +const ruleTester = new TSESLint.RuleTester() ruleTester.run('no-internal-modules', rule, { valid: [ diff --git a/test/rules/no-mutable-exports.spec.js b/test/rules/no-mutable-exports.spec.ts similarity index 92% rename from test/rules/no-mutable-exports.spec.js rename to test/rules/no-mutable-exports.spec.ts index 8a81f1327..f90d5759c 100644 --- a/test/rules/no-mutable-exports.spec.js +++ b/test/rules/no-mutable-exports.spec.ts @@ -1,11 +1,13 @@ +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/no-mutable-exports' + import { parsers, test, testVersion } from '../utils' -import { RuleTester } from 'eslint' -import rule from 'rules/no-mutable-exports' -const ruleTester = new RuleTester() +const ruleTester = new TSESLint.RuleTester() ruleTester.run('no-mutable-exports', rule, { - valid: [].concat( + valid: [ test({ code: 'export const count = 1' }), test({ code: 'export function getCount() {}' }), test({ code: 'export class Counter {}' }), @@ -33,12 +35,12 @@ ruleTester.run('no-mutable-exports', rule, { code: 'type Foo = {}\nexport type {Foo}', }), // es2022: Arbitrary module namespace identifier names - testVersion('>= 8.7', () => ({ + ...testVersion('>= 8.7', () => ({ code: 'const count = 1\nexport { count as "counter" }', parserOptions: { ecmaVersion: 2022 }, })), - ), - invalid: [].concat( + ], + invalid: [ test({ code: 'export let count = 1', errors: ["Exporting mutable 'let' binding, use 'const' instead."], @@ -72,7 +74,7 @@ ruleTester.run('no-mutable-exports', rule, { errors: ["Exporting mutable 'var' binding, use 'const' instead."], }), // es2022: Arbitrary module namespace identifier names - testVersion('>= 8.7', () => ({ + ...testVersion('>= 8.7', () => ({ code: 'let count = 1\nexport { count as "counter" }', errors: ["Exporting mutable 'let' binding, use 'const' instead."], parserOptions: { ecmaVersion: 2022 }, @@ -87,5 +89,5 @@ ruleTester.run('no-mutable-exports', rule, { // code: 'count = 1\nexport default count', // errors: ['Exporting mutable global binding, use \'const\' instead.'], // }), - ), + ], }) diff --git a/test/rules/no-namespace.spec.js b/test/rules/no-namespace.spec.js deleted file mode 100644 index 6be98faeb..000000000 --- a/test/rules/no-namespace.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -import { RuleTester } from 'eslint' -import eslintPkg from 'eslint/package.json' -import semver from 'semver' -import { test } from '../utils' - -const ERROR_MESSAGE = 'Unexpected namespace import.' - -const ruleTester = new RuleTester() - -// --fix functionality requires ESLint 5+ -const FIX_TESTS = semver.satisfies(eslintPkg.version, '>5.0.0') - ? [ - test({ - code: ` - import * as foo from './foo'; - florp(foo.bar); - florp(foo['baz']); - `.trim(), - output: ` - import { bar, baz } from './foo'; - florp(bar); - florp(baz); - `.trim(), - errors: [ - { - line: 1, - column: 8, - message: ERROR_MESSAGE, - }, - ], - }), - test({ - code: ` - import * as foo from './foo'; - const bar = 'name conflict'; - const baz = 'name conflict'; - const foo_baz = 'name conflict'; - florp(foo.bar); - florp(foo['baz']); - `.trim(), - output: ` - import { bar as foo_bar, baz as foo_baz_1 } from './foo'; - const bar = 'name conflict'; - const baz = 'name conflict'; - const foo_baz = 'name conflict'; - florp(foo_bar); - florp(foo_baz_1); - `.trim(), - errors: [ - { - line: 1, - column: 8, - message: ERROR_MESSAGE, - }, - ], - }), - test({ - code: ` - import * as foo from './foo'; - function func(arg) { - florp(foo.func); - florp(foo['arg']); - } - `.trim(), - output: ` - import { func as foo_func, arg as foo_arg } from './foo'; - function func(arg) { - florp(foo_func); - florp(foo_arg); - } - `.trim(), - errors: [ - { - line: 1, - column: 8, - message: ERROR_MESSAGE, - }, - ], - }), - ] - : [] - -ruleTester.run('no-namespace', require('rules/no-namespace'), { - valid: [ - { - code: "import { a, b } from 'foo';", - parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { - code: "import { a, b } from './foo';", - parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { - code: "import bar from 'bar';", - parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { - code: "import bar from './bar';", - parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - }, - { - code: "import * as bar from './ignored-module.ext';", - parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, - options: [{ ignore: ['*.ext'] }], - }, - ], - - invalid: [ - test({ - code: "import * as foo from 'foo';", - output: "import * as foo from 'foo';", - errors: [ - { - line: 1, - column: 8, - message: ERROR_MESSAGE, - }, - ], - }), - test({ - code: "import defaultExport, * as foo from 'foo';", - output: "import defaultExport, * as foo from 'foo';", - errors: [ - { - line: 1, - column: 23, - message: ERROR_MESSAGE, - }, - ], - }), - test({ - code: "import * as foo from './foo';", - output: "import * as foo from './foo';", - errors: [ - { - line: 1, - column: 8, - message: ERROR_MESSAGE, - }, - ], - }), - ...FIX_TESTS, - ], -}) diff --git a/test/rules/no-namespace.spec.ts b/test/rules/no-namespace.spec.ts new file mode 100644 index 000000000..e03bcae2b --- /dev/null +++ b/test/rules/no-namespace.spec.ts @@ -0,0 +1,138 @@ +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/no-namespace' + +import { test } from '../utils' + +const ERROR_MESSAGE = 'Unexpected namespace import.' + +const ruleTester = new TSESLint.RuleTester() + +ruleTester.run('no-namespace', rule, { + valid: [ + { + code: "import { a, b } from 'foo';", + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + }, + { + code: "import { a, b } from './foo';", + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + }, + { + code: "import bar from 'bar';", + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + }, + { + code: "import bar from './bar';", + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + }, + { + code: "import * as bar from './ignored-module.ext';", + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + options: [{ ignore: ['*.ext'] }], + }, + ], + + invalid: [ + test({ + code: "import * as foo from 'foo';", + output: "import * as foo from 'foo';", + errors: [ + { + line: 1, + column: 8, + message: ERROR_MESSAGE, + }, + ], + }), + test({ + code: "import defaultExport, * as foo from 'foo';", + output: "import defaultExport, * as foo from 'foo';", + errors: [ + { + line: 1, + column: 23, + message: ERROR_MESSAGE, + }, + ], + }), + test({ + code: "import * as foo from './foo';", + output: "import * as foo from './foo';", + errors: [ + { + line: 1, + column: 8, + message: ERROR_MESSAGE, + }, + ], + }), + test({ + code: ` + import * as foo from './foo'; + florp(foo.bar); + florp(foo['baz']); + `.trim(), + output: ` + import { bar, baz } from './foo'; + florp(bar); + florp(baz); + `.trim(), + errors: [ + { + line: 1, + column: 8, + message: ERROR_MESSAGE, + }, + ], + }), + test({ + code: ` + import * as foo from './foo'; + const bar = 'name conflict'; + const baz = 'name conflict'; + const foo_baz = 'name conflict'; + florp(foo.bar); + florp(foo['baz']); + `.trim(), + output: ` + import { bar as foo_bar, baz as foo_baz_1 } from './foo'; + const bar = 'name conflict'; + const baz = 'name conflict'; + const foo_baz = 'name conflict'; + florp(foo_bar); + florp(foo_baz_1); + `.trim(), + errors: [ + { + line: 1, + column: 8, + message: ERROR_MESSAGE, + }, + ], + }), + test({ + code: ` + import * as foo from './foo'; + function func(arg) { + florp(foo.func); + florp(foo['arg']); + } + `.trim(), + output: ` + import { func as foo_func, arg as foo_arg } from './foo'; + function func(arg) { + florp(foo_func); + florp(foo_arg); + } + `.trim(), + errors: [ + { + line: 1, + column: 8, + message: ERROR_MESSAGE, + }, + ], + }), + ], +}) diff --git a/test/rules/no-relative-packages.spec.js b/test/rules/no-relative-packages.spec.ts similarity index 94% rename from test/rules/no-relative-packages.spec.js rename to test/rules/no-relative-packages.spec.ts index 592ac3c1f..d0de54404 100644 --- a/test/rules/no-relative-packages.spec.js +++ b/test/rules/no-relative-packages.spec.ts @@ -1,10 +1,12 @@ -import { RuleTester } from 'eslint' -import rule from 'rules/no-relative-packages' import { normalize } from 'path' +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/no-relative-packages' + import { test, testFilePath } from '../utils' -const ruleTester = new RuleTester() +const ruleTester = new TSESLint.RuleTester() ruleTester.run('no-relative-packages', rule, { valid: [ diff --git a/test/rules/no-relative-parent-imports.spec.js b/test/rules/no-relative-parent-imports.spec.ts similarity index 89% rename from test/rules/no-relative-parent-imports.spec.js rename to test/rules/no-relative-parent-imports.spec.ts index ed840fa09..99a6670ca 100644 --- a/test/rules/no-relative-parent-imports.spec.js +++ b/test/rules/no-relative-parent-imports.spec.ts @@ -1,16 +1,17 @@ -import { RuleTester } from 'eslint' -import rule from 'rules/no-relative-parent-imports' -import { parsers, test as _test, testFilePath } from '../utils' +import { TSESLint } from '@typescript-eslint/utils' -const test = def => - _test( - Object.assign(def, { - filename: testFilePath('./internal-modules/plugins/plugin2/index.js'), - parser: parsers.BABEL, - }), - ) +import rule from '../../src/rules/no-relative-parent-imports' + +import { parsers, test as _test, testFilePath, ValidTestCase } from '../utils' + +const test = (def: T) => + _test({ + ...def, + filename: testFilePath('./internal-modules/plugins/plugin2/index.js'), + parser: parsers.BABEL, + }) -const ruleTester = new RuleTester() +const ruleTester = new TSESLint.RuleTester() ruleTester.run('no-relative-parent-imports', rule, { valid: [ diff --git a/test/rules/no-restricted-paths.spec.js b/test/rules/no-restricted-paths.spec.ts similarity index 96% rename from test/rules/no-restricted-paths.spec.js rename to test/rules/no-restricted-paths.spec.ts index 8f9fffa3a..c44dbdc92 100644 --- a/test/rules/no-restricted-paths.spec.js +++ b/test/rules/no-restricted-paths.spec.ts @@ -1,12 +1,13 @@ -import { RuleTester } from 'eslint' -import rule from 'rules/no-restricted-paths' +import { TSESLint } from '@typescript-eslint/utils' + +import rule from '../../src/rules/no-restricted-paths' import { parsers, test, testFilePath } from '../utils' -const ruleTester = new RuleTester() +const ruleTester = new TSESLint.RuleTester() ruleTester.run('no-restricted-paths', rule, { - valid: [].concat( + valid: [ test({ code: 'import a from "../client/a.js"', filename: testFilePath('./restricted-paths/server/b.js'), @@ -264,9 +265,9 @@ ruleTester.run('no-restricted-paths', rule, { // builtin (ignore) test({ code: 'require("os")' }), - ), + ], - invalid: [].concat( + invalid: [ test({ code: 'import b from "../server/b.js"; // 1', filename: testFilePath('./restricted-paths/client/a.js'), @@ -312,30 +313,32 @@ ruleTester.run('no-restricted-paths', rule, { ], }), // TODO: fix test on windows - process.platform === 'win32' + ...(process.platform === 'win32' ? [] - : test({ - code: 'import b from "../server/b.js";', - filename: testFilePath('./restricted-paths/client/a.js'), - options: [ - { - zones: [ - { - target: './test/fixtures/restricted-paths/client/*.js', - from: './test/fixtures/restricted-paths/server', - }, - ], - }, - ], - errors: [ - { - message: - 'Unexpected path "../server/b.js" imported in restricted zone.', - line: 1, - column: 15, - }, - ], - }), + : [ + test({ + code: 'import b from "../server/b.js";', + filename: testFilePath('./restricted-paths/client/a.js'), + options: [ + { + zones: [ + { + target: './test/fixtures/restricted-paths/client/*.js', + from: './test/fixtures/restricted-paths/server', + }, + ], + }, + ], + errors: [ + { + message: + 'Unexpected path "../server/b.js" imported in restricted zone.', + line: 1, + column: 15, + }, + ], + }), + ]), test({ code: 'import b from "../server/b.js"; // 2 ter', filename: testFilePath('./restricted-paths/client/a.js'), @@ -757,7 +760,7 @@ ruleTester.run('no-restricted-paths', rule, { }, ], }), - ), + ], }) describe('Typescript', () => { diff --git a/test/utils.ts b/test/utils.ts index 4c767f4ad..4d46daf6e 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,6 +1,7 @@ import path from 'path' import { TSESLint, TSESTree } from '@typescript-eslint/utils' +import { RuleTester } from 'eslint' import eslintPkg from 'eslint/package.json' import semver from 'semver' import typescriptPkg from 'typescript/package.json' @@ -62,18 +63,13 @@ export function testVersion( export type InvalidTestCaseError = | string | InvalidTestCase['errors'][number] - | { + | (RuleTester.TestCaseError & { type?: `${TSESTree.AST_NODE_TYPES}` - message: string - line?: number - column?: number - endLine?: number - endColumn?: number - } + }) export function test< T extends ValidTestCase & { - errors?: InvalidTestCaseError[] + errors?: readonly InvalidTestCaseError[] }, >( t: T, diff --git a/yarn.lock b/yarn.lock index 01255d4a0..9dba15e13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1867,6 +1867,11 @@ dependencies: "@types/node" "*" +"@types/is-glob@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/is-glob/-/is-glob-4.0.4.tgz#1d60fa47ff70abc97b4d9ea45328747c488b3a50" + integrity sha512-3mFBtIPQ0TQetKRDe94g8YrxJZxdMillMGegyv6zRBXvq4peRRhf2wLZ/Dl53emtTsC29dQQBwYvovS20yXpiQ== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7"