From 0381a02ab0929dc2e377133cccd5cc6afbaaaf07 Mon Sep 17 00:00:00 2001 From: Daniel <3473356+D4N14L@users.noreply.github.com> Date: Wed, 14 Aug 2024 15:18:48 -0700 Subject: [PATCH] [eslint-plugin] Add new import-related rules to the ESLint plugin (#4889) * Add new eslint rules * Consume new eslint rules in Rushstack * Update to newer eslint * Fix lint issues * Disable fixes when running in production mode * Rush change --- apps/heft/src/cli/HeftActionRunner.ts | 2 +- apps/heft/src/pluginFramework/HeftTask.ts | 4 +- apps/lockfile-explorer-web/src/store/hooks.ts | 2 +- ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + ...er-danade-LintTweaks_2024-08-14-21-07.json | 10 + eslint/eslint-plugin/src/LintUtilities.ts | 119 ++++++++++ eslint/eslint-plugin/src/index.ts | 16 ++ .../eslint-plugin/src/no-backslash-imports.ts | 71 ++++++ .../src/no-external-local-imports.ts | 66 ++++++ .../src/no-transitive-dependency-imports.ts | 68 ++++++ .../eslint-plugin/src/normalized-imports.ts | 85 +++++++ .../src/test/no-backslash-imports.test.ts | 150 ++++++++++++ .../test/no-external-local-imports.test.ts | 168 ++++++++++++++ .../no-transitive-dependency-imports.test.ts | 134 +++++++++++ .../src/test/normalized-imports.test.ts | 215 ++++++++++++++++++ eslint/eslint-plugin/tsconfig.json | 5 +- eslint/local-eslint-config/profile/_common.js | 17 ++ .../heft-lint-plugin/heft-plugin.json | 2 +- .../heft-lint-plugin/src/LintPlugin.ts | 17 +- .../src/schemas/heft-lint-plugin.schema.json | 2 +- .../src/items/ApiPropertyItem.ts | 6 +- .../load-themed-styles/src/test/index.test.ts | 2 +- .../rush-lib/src/api/RushConfiguration.ts | 2 +- .../src/api/RushConfigurationProject.ts | 2 +- .../src/cli/actions/BaseRushAction.ts | 2 +- libraries/rush-lib/src/logic/PurgeManager.ts | 2 +- libraries/rush-lib/src/logic/SetupChecks.ts | 2 +- .../src/logic/base/BaseInstallManager.ts | 2 +- .../src/logic/base/BaseShrinkwrapFile.ts | 2 +- .../installManager/RushInstallManager.ts | 6 +- .../installManager/WorkspaceInstallManager.ts | 4 +- .../rush-lib/src/logic/npm/NpmLinkManager.ts | 2 +- .../src/logic/pnpm/PnpmLinkManager.ts | 2 +- .../src/test/LoadThemedStylesLoader.test.ts | 2 +- 39 files changed, 1219 insertions(+), 32 deletions(-) create mode 100644 common/changes/@microsoft/api-extractor-model/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 common/changes/@microsoft/load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 common/changes/@microsoft/loader-load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 common/changes/@microsoft/rush/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 common/changes/@rushstack/eslint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 common/changes/@rushstack/heft-lint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 common/changes/@rushstack/heft/user-danade-LintTweaks_2024-08-14-21-07.json create mode 100644 eslint/eslint-plugin/src/LintUtilities.ts create mode 100644 eslint/eslint-plugin/src/no-backslash-imports.ts create mode 100644 eslint/eslint-plugin/src/no-external-local-imports.ts create mode 100644 eslint/eslint-plugin/src/no-transitive-dependency-imports.ts create mode 100644 eslint/eslint-plugin/src/normalized-imports.ts create mode 100644 eslint/eslint-plugin/src/test/no-backslash-imports.test.ts create mode 100644 eslint/eslint-plugin/src/test/no-external-local-imports.test.ts create mode 100644 eslint/eslint-plugin/src/test/no-transitive-dependency-imports.test.ts create mode 100644 eslint/eslint-plugin/src/test/normalized-imports.test.ts diff --git a/apps/heft/src/cli/HeftActionRunner.ts b/apps/heft/src/cli/HeftActionRunner.ts index 5a283e1ddc5..d0cb2e60765 100644 --- a/apps/heft/src/cli/HeftActionRunner.ts +++ b/apps/heft/src/cli/HeftActionRunner.ts @@ -29,7 +29,7 @@ import { HeftParameterManager } from '../pluginFramework/HeftParameterManager'; import { TaskOperationRunner } from '../operations/runners/TaskOperationRunner'; import { PhaseOperationRunner } from '../operations/runners/PhaseOperationRunner'; import type { HeftPhase } from '../pluginFramework/HeftPhase'; -import type { IHeftAction, IHeftActionOptions } from '../cli/actions/IHeftAction'; +import type { IHeftAction, IHeftActionOptions } from './actions/IHeftAction'; import type { IHeftLifecycleCleanHookOptions, IHeftLifecycleSession, diff --git a/apps/heft/src/pluginFramework/HeftTask.ts b/apps/heft/src/pluginFramework/HeftTask.ts index 287d86e11c6..b7b8c539126 100644 --- a/apps/heft/src/pluginFramework/HeftTask.ts +++ b/apps/heft/src/pluginFramework/HeftTask.ts @@ -13,8 +13,8 @@ import type { IHeftConfigurationJsonTaskSpecifier, IHeftConfigurationJsonPluginSpecifier } from '../utilities/CoreConfigFiles'; -import type { IHeftTaskPlugin } from '../pluginFramework/IHeftPlugin'; -import type { IScopedLogger } from '../pluginFramework/logging/ScopedLogger'; +import type { IHeftTaskPlugin } from './IHeftPlugin'; +import type { IScopedLogger } from './logging/ScopedLogger'; const RESERVED_TASK_NAMES: Set = new Set(['clean']); diff --git a/apps/lockfile-explorer-web/src/store/hooks.ts b/apps/lockfile-explorer-web/src/store/hooks.ts index 71638ae5532..1d31107cdb4 100644 --- a/apps/lockfile-explorer-web/src/store/hooks.ts +++ b/apps/lockfile-explorer-web/src/store/hooks.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import { type TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import type { RootState, AppDispatch } from './'; +import type { RootState, AppDispatch } from '.'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; diff --git a/common/changes/@microsoft/api-extractor-model/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@microsoft/api-extractor-model/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..52e967a8e74 --- /dev/null +++ b/common/changes/@microsoft/api-extractor-model/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/api-extractor-model", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/api-extractor-model" +} \ No newline at end of file diff --git a/common/changes/@microsoft/load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@microsoft/load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..3456eae9593 --- /dev/null +++ b/common/changes/@microsoft/load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/load-themed-styles", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/load-themed-styles" +} \ No newline at end of file diff --git a/common/changes/@microsoft/loader-load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@microsoft/loader-load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..23bb9c24d0b --- /dev/null +++ b/common/changes/@microsoft/loader-load-themed-styles/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/loader-load-themed-styles", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/loader-load-themed-styles" +} \ No newline at end of file diff --git a/common/changes/@microsoft/rush/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@microsoft/rush/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..bd7ff97cb34 --- /dev/null +++ b/common/changes/@microsoft/rush/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/eslint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@rushstack/eslint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..ddb439d2f76 --- /dev/null +++ b/common/changes/@rushstack/eslint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-plugin", + "comment": "Add 4 new ESLint rules: \"@rushstack/no-backslash-imports\", used to prevent backslashes in import and require statements; \"@rushstack/no-external-local-imports\", used to prevent referencing external depedencies in import and require statements; \"@rushstack/no-transitive-dependency-imports\", used to prevent referencing transitive dependencies (ie. dependencies of dependencies) in import and require statements; and \"@rushstack/normalized-imports\", used to ensure that the most direct path to a dependency is provided in import and require statements", + "type": "minor" + } + ], + "packageName": "@rushstack/eslint-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft-lint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@rushstack/heft-lint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..e7631b46f85 --- /dev/null +++ b/common/changes/@rushstack/heft-lint-plugin/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-lint-plugin", + "comment": "Unintrusively disable \"--fix\" mode when running in \"--production\" mode", + "type": "patch" + } + ], + "packageName": "@rushstack/heft-lint-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft/user-danade-LintTweaks_2024-08-14-21-07.json b/common/changes/@rushstack/heft/user-danade-LintTweaks_2024-08-14-21-07.json new file mode 100644 index 00000000000..4da3f257a2d --- /dev/null +++ b/common/changes/@rushstack/heft/user-danade-LintTweaks_2024-08-14-21-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "", + "type": "none" + } + ], + "packageName": "@rushstack/heft" +} \ No newline at end of file diff --git a/eslint/eslint-plugin/src/LintUtilities.ts b/eslint/eslint-plugin/src/LintUtilities.ts new file mode 100644 index 00000000000..c7cc51933ec --- /dev/null +++ b/eslint/eslint-plugin/src/LintUtilities.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import { ESLintUtils, TSESTree, type TSESLint } from '@typescript-eslint/utils'; +import type { Program } from 'typescript'; + +export interface IParsedImportSpecifier { + loader?: string; + importTarget: string; + loaderOptions?: string; +} + +// Regex to parse out the import target from the specifier. Expected formats are: +// - '' +// - '!' +// - '?' +// - '!?' +const LOADER_CAPTURE_GROUP: 'loader' = 'loader'; +const IMPORT_TARGET_CAPTURE_GROUP: 'importTarget' = 'importTarget'; +const LOADER_OPTIONS_CAPTURE_GROUP: 'loaderOptions' = 'loaderOptions'; +// eslint-disable-next-line @rushstack/security/no-unsafe-regexp +const SPECIFIER_REGEX: RegExp = new RegExp( + `^((?<${LOADER_CAPTURE_GROUP}>(!|-!|!!).+)!)?` + + `(?<${IMPORT_TARGET_CAPTURE_GROUP}>[^!?]+)` + + `(\\?(?<${LOADER_OPTIONS_CAPTURE_GROUP}>.*))?$` +); + +export function getFilePathFromContext(context: TSESLint.RuleContext): string { + return context.physicalFilename || context.filename; +} + +export function getRootDirectoryFromContext( + context: TSESLint.RuleContext +): string | undefined { + let rootDirectory: string | undefined; + try { + // First attempt to get the root directory from the tsconfig baseUrl, then the program current directory + const program: Program | null | undefined = ( + context.sourceCode?.parserServices ?? ESLintUtils.getParserServices(context) + ).program; + rootDirectory = program?.getCompilerOptions().baseUrl ?? program?.getCurrentDirectory(); + } catch { + // Ignore the error if we cannot retrieve a TS program + } + + // Fall back to the parserOptions.tsconfigRootDir if available, otherwise the eslint working directory + if (!rootDirectory) { + rootDirectory = context.parserOptions?.tsconfigRootDir ?? context.getCwd?.(); + } + + return rootDirectory; +} + +export function parseImportSpecifierFromExpression( + importExpression: TSESTree.Expression +): IParsedImportSpecifier | undefined { + if ( + !importExpression || + importExpression.type !== TSESTree.AST_NODE_TYPES.Literal || + typeof importExpression.value !== 'string' + ) { + // Can't determine the path of the import target, return + return undefined; + } + + // Extract the target of the import, stripping out webpack loaders and query strings. The regex will + // also ensure that the import target is a relative path. + const specifierMatch: RegExpMatchArray | null = importExpression.value.match(SPECIFIER_REGEX); + if (!specifierMatch?.groups) { + // Can't determine the path of the import target, return + return undefined; + } + + const loader: string | undefined = specifierMatch.groups[LOADER_CAPTURE_GROUP]; + const importTarget: string = specifierMatch.groups[IMPORT_TARGET_CAPTURE_GROUP]; + const loaderOptions: string | undefined = specifierMatch.groups[LOADER_OPTIONS_CAPTURE_GROUP]; + return { loader, importTarget, loaderOptions }; +} + +export function serializeImportSpecifier(parsedImportPath: IParsedImportSpecifier): string { + const { loader, importTarget, loaderOptions } = parsedImportPath; + return `${loader ? `${loader}!` : ''}${importTarget}${loaderOptions ? `?${loaderOptions}` : ''}`; +} + +export function getImportPathFromExpression( + importExpression: TSESTree.Expression, + relativeImportsOnly: boolean = true +): string | undefined { + const parsedImportSpecifier: IParsedImportSpecifier | undefined = + parseImportSpecifierFromExpression(importExpression); + if ( + !parsedImportSpecifier || + (relativeImportsOnly && !parsedImportSpecifier.importTarget.startsWith('.')) + ) { + // The import target isn't a path, return + return undefined; + } + return parsedImportSpecifier?.importTarget; +} + +export function getImportAbsolutePathFromExpression( + context: TSESLint.RuleContext, + importExpression: TSESTree.Expression, + relativeImportsOnly: boolean = true +): string | undefined { + const importPath: string | undefined = getImportPathFromExpression(importExpression, relativeImportsOnly); + if (importPath === undefined) { + // Can't determine the absolute path of the import target, return + return undefined; + } + + const filePath: string = getFilePathFromContext(context); + const fileDirectory: string = path.dirname(filePath); + + // Combine the import path with the absolute path of the file parent directory to get the + // absolute path of the import target + return path.resolve(fileDirectory, importPath); +} diff --git a/eslint/eslint-plugin/src/index.ts b/eslint/eslint-plugin/src/index.ts index 518f9fc4925..000c895a10f 100644 --- a/eslint/eslint-plugin/src/index.ts +++ b/eslint/eslint-plugin/src/index.ts @@ -4,9 +4,13 @@ import { TSESLint } from '@typescript-eslint/utils'; import { hoistJestMock } from './hoist-jest-mock'; +import { noBackslashImportsRule } from './no-backslash-imports'; +import { noExternalLocalImportsRule } from './no-external-local-imports'; import { noNewNullRule } from './no-new-null'; import { noNullRule } from './no-null'; +import { noTransitiveDependencyImportsRule } from './no-transitive-dependency-imports'; import { noUntypedUnderscoreRule } from './no-untyped-underscore'; +import { normalizedImportsRule } from './normalized-imports'; import { typedefVar } from './typedef-var'; interface IPlugin { @@ -18,15 +22,27 @@ const plugin: IPlugin = { // Full name: "@rushstack/hoist-jest-mock" 'hoist-jest-mock': hoistJestMock, + // Full name: "@rushstack/no-backslash-imports" + 'no-backslash-imports': noBackslashImportsRule, + + // Full name: "@rushstack/no-external-local-imports" + 'no-external-local-imports': noExternalLocalImportsRule, + // Full name: "@rushstack/no-new-null" 'no-new-null': noNewNullRule, // Full name: "@rushstack/no-null" 'no-null': noNullRule, + // Full name: "@rushstack/no-transitive-dependency-imports" + 'no-transitive-dependency-imports': noTransitiveDependencyImportsRule, + // Full name: "@rushstack/no-untyped-underscore" 'no-untyped-underscore': noUntypedUnderscoreRule, + // Full name: "@rushstack/normalized-imports" + 'normalized-imports': normalizedImportsRule, + // Full name: "@rushstack/typedef-var" 'typedef-var': typedefVar } diff --git a/eslint/eslint-plugin/src/no-backslash-imports.ts b/eslint/eslint-plugin/src/no-backslash-imports.ts new file mode 100644 index 00000000000..a4ad28f756b --- /dev/null +++ b/eslint/eslint-plugin/src/no-backslash-imports.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { + parseImportSpecifierFromExpression, + serializeImportSpecifier, + type IParsedImportSpecifier +} from './LintUtilities'; + +export const MESSAGE_ID: 'no-backslash-imports' = 'no-backslash-imports'; +type RuleModule = TSESLint.RuleModule; +type RuleContext = TSESLint.RuleContext; + +export const noBackslashImportsRule: RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + messages: { + [MESSAGE_ID]: 'The specified import target path contains backslashes.' + }, + schema: [], + docs: { + description: 'Prevents imports using paths that use backslashes', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + }, + fixable: 'code' + }, + create: (context: RuleContext) => { + const checkImportExpression: (importExpression: TSESTree.Expression | null) => void = ( + importExpression: TSESTree.Expression | null + ) => { + if (!importExpression) { + // Can't validate, return + return; + } + + // Determine the target file path and find the most direct relative path from the source file + const importSpecifier: IParsedImportSpecifier | undefined = + parseImportSpecifierFromExpression(importExpression); + if (importSpecifier === undefined) { + // Can't validate, return + return; + } + + // Check if the import path contains backslashes. If it does, suggest a fix to replace them with forward + // slashes. + const { importTarget } = importSpecifier; + if (importTarget.includes('\\')) { + context.report({ + node: importExpression, + messageId: MESSAGE_ID, + fix: (fixer: TSESLint.RuleFixer) => { + const normalizedSpecifier: IParsedImportSpecifier = { + ...importSpecifier, + importTarget: importTarget.replace(/\\/g, '/') + }; + return fixer.replaceText(importExpression, `'${serializeImportSpecifier(normalizedSpecifier)}'`); + } + }); + } + }; + + return { + ImportDeclaration: (node: TSESTree.ImportDeclaration) => checkImportExpression(node.source), + ImportExpression: (node: TSESTree.ImportExpression) => checkImportExpression(node.source), + ExportAllDeclaration: (node: TSESTree.ExportAllDeclaration) => checkImportExpression(node.source), + ExportNamedDeclaration: (node: TSESTree.ExportNamedDeclaration) => checkImportExpression(node.source) + }; + } +}; diff --git a/eslint/eslint-plugin/src/no-external-local-imports.ts b/eslint/eslint-plugin/src/no-external-local-imports.ts new file mode 100644 index 00000000000..7466b765910 --- /dev/null +++ b/eslint/eslint-plugin/src/no-external-local-imports.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { getRootDirectoryFromContext, getImportAbsolutePathFromExpression } from './LintUtilities'; + +export const MESSAGE_ID: 'error-external-local-imports' = 'error-external-local-imports'; +type RuleModule = TSESLint.RuleModule; +type RuleContext = TSESLint.RuleContext; + +const _relativePathRegex: RegExp = /^[.\/\\]+$/; + +export const noExternalLocalImportsRule: RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + messages: { + [MESSAGE_ID]: + 'The specified import target is not under the root directory. Ensure that ' + + 'all local import targets are either under the "rootDir" specified in your tsconfig.json (if one ' + + 'exists) or under the package directory.' + }, + schema: [], + docs: { + description: + 'Prevents referencing relative imports that are either not under the "rootDir" specified in ' + + 'the tsconfig.json (if one exists) or not under the package directory.', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + } + }, + create: (context: RuleContext) => { + const rootDirectory: string | undefined = getRootDirectoryFromContext(context); + const checkImportExpression: (importExpression: TSESTree.Expression | null) => void = ( + importExpression: TSESTree.Expression | null + ) => { + if (!importExpression || !rootDirectory) { + // Can't validate, return + return; + } + + // Get the relative path between the target and the root. If the target is under the root, then the resulting + // relative path should be a series of "../" segments. + const importAbsolutePath: string | undefined = getImportAbsolutePathFromExpression( + context, + importExpression + ); + if (!importAbsolutePath) { + // Can't validate, return + return; + } + + const relativePathToRoot: string = path.relative(importAbsolutePath, rootDirectory); + if (!_relativePathRegex.test(relativePathToRoot)) { + context.report({ node: importExpression, messageId: MESSAGE_ID }); + } + }; + + return { + ImportDeclaration: (node: TSESTree.ImportDeclaration) => checkImportExpression(node.source), + ImportExpression: (node: TSESTree.ImportExpression) => checkImportExpression(node.source), + ExportAllDeclaration: (node: TSESTree.ExportAllDeclaration) => checkImportExpression(node.source), + ExportNamedDeclaration: (node: TSESTree.ExportNamedDeclaration) => checkImportExpression(node.source) + }; + } +}; diff --git a/eslint/eslint-plugin/src/no-transitive-dependency-imports.ts b/eslint/eslint-plugin/src/no-transitive-dependency-imports.ts new file mode 100644 index 00000000000..04df0d0a2a7 --- /dev/null +++ b/eslint/eslint-plugin/src/no-transitive-dependency-imports.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { parseImportSpecifierFromExpression, type IParsedImportSpecifier } from './LintUtilities'; + +export const MESSAGE_ID: 'error-transitive-dependency-imports' = 'error-transitive-dependency-imports'; +type RuleModule = TSESLint.RuleModule; +type RuleContext = TSESLint.RuleContext; + +const NODE_MODULES_PATH_SEGMENT: '/node_modules/' = '/node_modules/'; + +export const noTransitiveDependencyImportsRule: RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + messages: { + [MESSAGE_ID]: 'The specified import targets a transitive dependency.' + }, + schema: [], + docs: { + description: + 'Prevents referencing imports that are transitive dependencies, ie. imports that are not ' + + 'direct dependencies of the package.', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + } + }, + create: (context: RuleContext) => { + const checkImportExpression: (importExpression: TSESTree.Expression | null) => void = ( + importExpression: TSESTree.Expression | null + ) => { + if (!importExpression) { + // Can't validate, return + return; + } + + const importSpecifier: IParsedImportSpecifier | undefined = + parseImportSpecifierFromExpression(importExpression); + if (importSpecifier === undefined) { + // Can't validate, return + return; + } + + // Check to see if node_modules is mentioned in the normalized import path more than once if + // the path is relative, or if it is mentioned at all if the path is to a package. + const { importTarget } = importSpecifier; + const isRelative: boolean = importTarget.startsWith('.'); + let nodeModulesIndex: number = importTarget.indexOf(NODE_MODULES_PATH_SEGMENT); + if (nodeModulesIndex >= 0 && isRelative) { + // We allow relative paths to node_modules one layer deep to deal with bypassing exports + nodeModulesIndex = importTarget.indexOf( + NODE_MODULES_PATH_SEGMENT, + nodeModulesIndex + NODE_MODULES_PATH_SEGMENT.length - 1 + ); + } + if (nodeModulesIndex >= 0) { + context.report({ node: importExpression, messageId: MESSAGE_ID }); + } + }; + + return { + ImportDeclaration: (node: TSESTree.ImportDeclaration) => checkImportExpression(node.source), + ImportExpression: (node: TSESTree.ImportExpression) => checkImportExpression(node.source), + ExportAllDeclaration: (node: TSESTree.ExportAllDeclaration) => checkImportExpression(node.source), + ExportNamedDeclaration: (node: TSESTree.ExportNamedDeclaration) => checkImportExpression(node.source) + }; + } +}; diff --git a/eslint/eslint-plugin/src/normalized-imports.ts b/eslint/eslint-plugin/src/normalized-imports.ts new file mode 100644 index 00000000000..f5ba7d927e1 --- /dev/null +++ b/eslint/eslint-plugin/src/normalized-imports.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { + getFilePathFromContext, + parseImportSpecifierFromExpression, + serializeImportSpecifier, + type IParsedImportSpecifier +} from './LintUtilities'; + +export const MESSAGE_ID: 'error-normalized-imports' = 'error-normalized-imports'; +type RuleModule = TSESLint.RuleModule; +type RuleContext = TSESLint.RuleContext; + +export const normalizedImportsRule: RuleModule = { + defaultOptions: [], + meta: { + type: 'suggestion', + messages: { + [MESSAGE_ID]: 'The specified import target path was not provided in a normalized form.' + }, + schema: [], + docs: { + description: + 'Prevents and normalizes references to relative imports using paths that make unnecessary ' + + 'traversals (ex. "../blah/module" in directory "blah" -> "./module")', + url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin' + }, + fixable: 'code' + }, + create: (context: RuleContext) => { + const checkImportExpression: (importExpression: TSESTree.Expression | null) => void = ( + importExpression: TSESTree.Expression | null + ) => { + if (!importExpression) { + // Can't validate, return + return; + } + + // Determine the target file path and find the most direct relative path from the source file + const importSpecifier: IParsedImportSpecifier | undefined = + parseImportSpecifierFromExpression(importExpression); + if (!importSpecifier || !importSpecifier.importTarget.startsWith('.')) { + // Can't validate, return + return; + } + const { importTarget } = importSpecifier; + const parentDirectory: string = path.dirname(getFilePathFromContext(context)); + const absoluteImportPath: string = path.resolve(parentDirectory, importTarget); + const relativeImportPath: string = path.relative(parentDirectory, absoluteImportPath); + + // Reconstruct the import target using posix separators and manually re-add the leading './' if needed + let normalizedImportPath: string = + path.sep !== '/' ? relativeImportPath.replace(/\\/g, '/') : relativeImportPath; + if (!normalizedImportPath.startsWith('.')) { + normalizedImportPath = `.${normalizedImportPath ? '/' : ''}${normalizedImportPath}`; + } + + // If they don't match, suggest the normalized path as a fix + if (importTarget !== normalizedImportPath) { + context.report({ + node: importExpression, + messageId: MESSAGE_ID, + fix: (fixer: TSESLint.RuleFixer) => { + // Re-include stripped loader and query strings, if provided + const normalizedSpecifier: string = serializeImportSpecifier({ + ...importSpecifier, + importTarget: normalizedImportPath + }); + return fixer.replaceText(importExpression, `'${normalizedSpecifier}'`); + } + }); + } + }; + + return { + ImportDeclaration: (node: TSESTree.ImportDeclaration) => checkImportExpression(node.source), + ImportExpression: (node: TSESTree.ImportExpression) => checkImportExpression(node.source), + ExportAllDeclaration: (node: TSESTree.ExportAllDeclaration) => checkImportExpression(node.source), + ExportNamedDeclaration: (node: TSESTree.ExportNamedDeclaration) => checkImportExpression(node.source) + }; + } +}; diff --git a/eslint/eslint-plugin/src/test/no-backslash-imports.test.ts b/eslint/eslint-plugin/src/test/no-backslash-imports.test.ts new file mode 100644 index 00000000000..18af55bc09e --- /dev/null +++ b/eslint/eslint-plugin/src/test/no-backslash-imports.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TSESLint } from '@typescript-eslint/utils'; +import { noBackslashImportsRule, MESSAGE_ID } from '../no-backslash-imports'; + +const { RuleTester } = TSESLint; +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser') +}); +const expectedErrors: TSESLint.TestCaseError[] = [{ messageId: MESSAGE_ID }]; + +ruleTester.run('no-backslash-imports', noBackslashImportsRule, { + invalid: [ + // Test variants + { + code: "import blah from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import blah from './foo/bar'" + }, + { + code: "import * as blah from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import * as blah from './foo/bar'" + }, + { + code: "import { blah } from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import { blah } from './foo/bar'" + }, + { + code: "import { _blah as Blah } from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, { _blah as Blah } from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import blah, { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, * as Blah from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import blah, * as Blah from './foo/bar'" + }, + { + code: "import '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import './foo/bar'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar'" + }, + { + code: "import blah from '.\\\\foo\\\\bar?source'", + errors: expectedErrors, + output: "import blah from './foo/bar?source'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!.\\\\foo\\\\bar?source'", + errors: expectedErrors, + output: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar?source'" + }, + { + code: "export * from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "export * from './foo/bar'" + }, + // { + // code: "export * as blah from './foo/../foo/bar'", + // errors: expectedErrors, + // output: "export * as blah from './foo/bar'" + // }, + { + code: "export { blah } from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "export { blah } from './foo/bar'" + }, + { + code: "export { _blah as Blah } from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "export { _blah as Blah } from './foo/bar'" + }, + { + code: "export { default } from '.\\\\foo\\\\bar'", + errors: expectedErrors, + output: "export { default } from './foo/bar'" + }, + // Test async imports + { + code: "const blah = await import('.\\\\foo\\\\bar')", + errors: expectedErrors, + output: "const blah = await import('./foo/bar')" + } + ], + valid: [ + // Test variants + { + code: "import blah from './foo/bar'" + }, + { + code: "import * as blah from './foo/bar'" + }, + { + code: "import { blah } from './foo/bar'" + }, + { + code: "import { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, * as Blah from './foo/bar'" + }, + { + code: "import './foo/bar'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar'" + }, + { + code: "import blah from './foo/bar?source'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar?source'" + }, + { + code: "export * from './foo/bar'" + }, + // { + // code: "export * as blah from './foo/bar'" + // }, + { + code: "export { blah } from './foo/bar'" + }, + { + code: "export { _blah as Blah } from './foo/bar'" + }, + { + code: "export { default } from './foo/bar'" + }, + // Test async imports + { + code: "const blah = await import('./foo/bar')" + } + ] +}); diff --git a/eslint/eslint-plugin/src/test/no-external-local-imports.test.ts b/eslint/eslint-plugin/src/test/no-external-local-imports.test.ts new file mode 100644 index 00000000000..8d3d821e474 --- /dev/null +++ b/eslint/eslint-plugin/src/test/no-external-local-imports.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TSESLint } from '@typescript-eslint/utils'; +import { noExternalLocalImportsRule } from '../no-external-local-imports'; + +const { RuleTester } = TSESLint; +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser') +}); + +// The root in the test cases is the immediate directory +ruleTester.run('no-external-local-imports', noExternalLocalImportsRule, { + invalid: [ + // Test variants + { + code: "import blah from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import * as blah from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import { blah } from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import { _blah as Blah } from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import blah, { _blah as Blah } from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import blah, * as Blah from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import blah from '../foo?source'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!../foo?source'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "export * from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + // { + // code: "export * as blah from '../foo'", + // errors: [{ messageId: 'error-external-local-imports' }] + // }, + { + code: "export { blah } from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "export { _blah as Blah } from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "export { default } from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }] + }, + // Test importing from outside of tsconfigRootDir + { + code: "import blah from '../foo'", + errors: [{ messageId: 'error-external-local-imports' }], + filename: 'blah/test.ts', + parserOptions: { + tsconfigRootDir: 'blah' + } + }, + // Test async imports + { + code: "const blah = await import('../foo')", + errors: [{ messageId: 'error-external-local-imports' }] + }, + { + code: "const blah = await import('../foo')", + errors: [{ messageId: 'error-external-local-imports' }], + filename: 'blah/test.ts', + parserOptions: { + tsconfigRootDir: 'blah' + } + } + ], + valid: [ + // Test variants + { + code: "import blah from './foo'" + }, + { + code: "import * as blah from './foo'" + }, + { + code: "import { blah } from './foo'" + }, + { + code: "import { _blah as Blah } from './foo'" + }, + { + code: "import blah, { _blah as Blah } from './foo'" + }, + { + code: "import blah, * as Blah from './foo'" + }, + { + code: "import './foo'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo'" + }, + { + code: "import blah from './foo?source'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo?source'" + }, + { + code: "export * from './foo'" + }, + // { + // code: "export * as blah from './foo'" + // }, + { + code: "export { blah } from './foo'" + }, + { + code: "export { _blah as Blah } from './foo'" + }, + { + code: "export { default } from './foo'" + }, + // Test that importing vertically within the project is valid + { + code: "import blah from '../foo/bar'", + filename: 'blah2/test.ts' + }, + { + code: "import blah from '../../foo/bar'", + filename: 'blah2/foo3/test.ts' + }, + // Test async imports + { + code: "const blah = await import('./foo')" + }, + { + code: "const blah = await import('../foo/bar')", + filename: 'blah2/test.ts' + }, + { + code: "const blah = await import('../../foo/bar')", + filename: 'blah2/foo3/test.ts' + } + ] +}); diff --git a/eslint/eslint-plugin/src/test/no-transitive-dependency-imports.test.ts b/eslint/eslint-plugin/src/test/no-transitive-dependency-imports.test.ts new file mode 100644 index 00000000000..1883b044e59 --- /dev/null +++ b/eslint/eslint-plugin/src/test/no-transitive-dependency-imports.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TSESLint } from '@typescript-eslint/utils'; +import { noTransitiveDependencyImportsRule, MESSAGE_ID } from '../no-transitive-dependency-imports'; + +const { RuleTester } = TSESLint; +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser') +}); +const expectedErrors: TSESLint.TestCaseError[] = [{ messageId: MESSAGE_ID }]; + +ruleTester.run('no-transitive-dependency-imports', noTransitiveDependencyImportsRule, { + invalid: [ + // Test variants + { + code: "import blah from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import * as blah from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import { blah } from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import { _blah as Blah } from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import blah, { _blah as Blah } from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import blah, * as Blah from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "import blah from './node_modules/foo/node_modules/bar?source'", + errors: expectedErrors + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./node_modules/foo/node_modules/bar?source'", + errors: expectedErrors + }, + { + code: "export * from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + // { + // code: "export * as blah from './node_modules/foo/node_modules/bar'", + // errors: expectedErrors + // }, + { + code: "export { blah } from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "export { _blah as Blah } from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + { + code: "export { default } from './node_modules/foo/node_modules/bar'", + errors: expectedErrors + }, + // Test async imports + { + code: "const blah = await import('./node_modules/foo/node_modules/bar')", + errors: expectedErrors + } + ], + valid: [ + // Test variants + { + code: "import blah from './node_modules/foo'" + }, + { + code: "import * as blah from './node_modules/foo'" + }, + { + code: "import { blah } from './node_modules/foo'" + }, + { + code: "import { _blah as Blah } from './node_modules/foo'" + }, + { + code: "import blah, { _blah as Blah } from './node_modules/foo'" + }, + { + code: "import blah, * as Blah from './node_modules/foo'" + }, + { + code: "import './node_modules/foo'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./node_modules/foo'" + }, + { + code: "import blah from './node_modules/foo?source'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./node_modules/foo?source'" + }, + { + code: "export * from './node_modules/foo'" + }, + // { + // code: "export * as blah from './node_modules/foo'" + // }, + { + code: "export { blah } from './node_modules/foo'" + }, + { + code: "export { _blah as Blah } from './node_modules/foo'" + }, + { + code: "export { default } from './node_modules/foo'" + }, + // Test async imports + { + code: "const blah = await import('./node_modules/foo')" + } + ] +}); diff --git a/eslint/eslint-plugin/src/test/normalized-imports.test.ts b/eslint/eslint-plugin/src/test/normalized-imports.test.ts new file mode 100644 index 00000000000..d4bcd965fbf --- /dev/null +++ b/eslint/eslint-plugin/src/test/normalized-imports.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TSESLint } from '@typescript-eslint/utils'; +import { normalizedImportsRule, MESSAGE_ID } from '../normalized-imports'; + +const { RuleTester } = TSESLint; +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser') +}); +const expectedErrors: TSESLint.TestCaseError[] = [{ messageId: MESSAGE_ID }]; + +// The root in the test cases is the immediate directory +ruleTester.run('normalized-imports', normalizedImportsRule, { + invalid: [ + // Test variants + { + code: "import blah from './foo/../foo/bar'", + errors: expectedErrors, + output: "import blah from './foo/bar'" + }, + { + code: "import * as blah from './foo/../foo/bar'", + errors: expectedErrors, + output: "import * as blah from './foo/bar'" + }, + { + code: "import { blah } from './foo/../foo/bar'", + errors: expectedErrors, + output: "import { blah } from './foo/bar'" + }, + { + code: "import { _blah as Blah } from './foo/../foo/bar'", + errors: expectedErrors, + output: "import { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, { _blah as Blah } from './foo/../foo/bar'", + errors: expectedErrors, + output: "import blah, { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, * as Blah from './foo/../foo/bar'", + errors: expectedErrors, + output: "import blah, * as Blah from './foo/bar'" + }, + { + code: "import './foo/../foo/bar'", + errors: expectedErrors, + output: "import './foo/bar'" + }, + // While directory imports aren't ideal, especially from the immediate directory, the path is normalized + { + code: "import blah from './'", + errors: expectedErrors, + output: "import blah from '.'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/../foo/bar'", + errors: expectedErrors, + output: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar'" + }, + { + code: "import blah from './foo/../foo/bar?source'", + errors: expectedErrors, + output: "import blah from './foo/bar?source'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/../foo/bar?source'", + errors: expectedErrors, + output: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar?source'" + }, + { + code: "export * from './foo/../foo/bar'", + errors: expectedErrors, + output: "export * from './foo/bar'" + }, + // { + // code: "export * as blah from './foo/../foo/bar'", + // errors: expectedErrors, + // output: "export * as blah from './foo/bar'" + // }, + { + code: "export { blah } from './foo/../foo/bar'", + errors: expectedErrors, + output: "export { blah } from './foo/bar'" + }, + { + code: "export { _blah as Blah } from './foo/../foo/bar'", + errors: expectedErrors, + output: "export { _blah as Blah } from './foo/bar'" + }, + { + code: "export { default } from './foo/../foo/bar'", + errors: expectedErrors, + output: "export { default } from './foo/bar'" + }, + // Test leaving and re-entering the current directory + { + code: "import blah from '../foo/bar'", + errors: expectedErrors, + output: "import blah from './bar'", + filename: 'foo/test.ts' + }, + { + code: "import blah from '../../foo/foo2/bar'", + errors: expectedErrors, + output: "import blah from './bar'", + filename: 'foo/foo2/test.ts' + }, + { + code: "import blah from '../../foo/bar'", + errors: expectedErrors, + output: "import blah from '../bar'", + filename: 'foo/foo2/test.ts' + }, + // Test async imports + { + code: "const blah = await import('./foo/../foo/bar')", + errors: expectedErrors, + output: "const blah = await import('./foo/bar')" + }, + { + code: "const blah = await import('../foo/bar')", + errors: expectedErrors, + output: "const blah = await import('./bar')", + filename: 'foo/test.ts' + }, + { + code: "const blah = await import('../../foo/foo2/bar')", + errors: expectedErrors, + output: "const blah = await import('./bar')", + filename: 'foo/foo2/test.ts' + }, + { + code: "const blah = await import('../../foo/bar')", + errors: expectedErrors, + output: "const blah = await import('../bar')", + filename: 'foo/foo2/test.ts' + } + ], + valid: [ + // Test variants + { + code: "import blah from './foo/bar'" + }, + { + code: "import * as blah from './foo/bar'" + }, + { + code: "import { blah } from './foo/bar'" + }, + { + code: "import { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, { _blah as Blah } from './foo/bar'" + }, + { + code: "import blah, * as Blah from './foo/bar'" + }, + { + code: "import './foo/bar'" + }, + // While directory imports aren't ideal, especially from the immediate directory, the path is normalized + { + code: "import blah from '.'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar'" + }, + { + code: "import blah from './foo/bar?source'" + }, + { + code: "import blah from '!!file-loader?name=image_[name]_[hash:8][ext]!./foo/bar?source'" + }, + { + code: "export * from './foo/bar'" + }, + // { + // code: "export * as blah from './foo/bar'" + // }, + { + code: "export { blah } from './foo/bar'" + }, + { + code: "export { _blah as Blah } from './foo/bar'" + }, + { + code: "export { default } from './foo/bar'" + }, + // Test that importing vertically is valid + { + code: "import blah from '../foo/bar'", + filename: 'foo2/test.ts' + }, + { + code: "import blah from '../../foo/bar'", + filename: 'foo2/foo3/test.ts' + }, + // Test async imports + { + code: "const blah = await import('./foo/bar')" + }, + { + code: "const blah = await import('../foo/bar')", + filename: 'foo2/test.ts' + }, + { + code: "const blah = import('../../foo/bar')", + filename: 'foo2/foo3/test.ts' + } + ] +}); diff --git a/eslint/eslint-plugin/tsconfig.json b/eslint/eslint-plugin/tsconfig.json index cfa885474e5..f54c12f4868 100644 --- a/eslint/eslint-plugin/tsconfig.json +++ b/eslint/eslint-plugin/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "isolatedModules": true, + "types": ["heft-jest", "node"], + "module": "Node16", - "types": ["heft-jest", "node"] + "target": "ES2020", + "lib": ["ES2020"] } } diff --git a/eslint/local-eslint-config/profile/_common.js b/eslint/local-eslint-config/profile/_common.js index 65b17303e1d..ef41902eff8 100644 --- a/eslint/local-eslint-config/profile/_common.js +++ b/eslint/local-eslint-config/profile/_common.js @@ -45,6 +45,23 @@ function buildRules(profile) { // The settings below revise the defaults specified in the extended configurations. files: ['*.ts', '*.tsx'], rules: { + // Rationale: Backslashes are platform-specific and will cause breaks on non-Windows + // platforms. + '@rushstack/no-backslash-imports': 'error', + + // Rationale: Avoid consuming dependencies which would not otherwise be present when + // the package is published. + '@rushstack/no-external-local-imports': 'error', + + // Rationale: Consumption of transitive dependencies can be problematic when the dependency + // is updated or removed from the parent package. Enforcing consumption of only direct dependencies + // ensures that the package is exactly what we expect it to be. + '@rushstack/no-transitive-dependency-imports': 'warn', + + // Rationale: Using the simplest possible import syntax is preferred and makes it easier to + // understand where the dependency is coming from. + '@rushstack/normalized-imports': 'warn', + // Rationale: Use of `void` to explicitly indicate that a floating promise is expected // and allowed. '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], diff --git a/heft-plugins/heft-lint-plugin/heft-plugin.json b/heft-plugins/heft-lint-plugin/heft-plugin.json index d905b684469..1de6fef351a 100644 --- a/heft-plugins/heft-lint-plugin/heft-plugin.json +++ b/heft-plugins/heft-lint-plugin/heft-plugin.json @@ -12,7 +12,7 @@ { "longName": "--fix", "parameterKind": "flag", - "description": "Fix all encountered rule violations where the violated rule provides a fixer." + "description": "Fix all encountered rule violations where the violated rule provides a fixer. When running in production mode, fixes will be disabled regardless of this parameter." } ] } diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 0f4b580b87a..faee0f156ab 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -56,6 +56,17 @@ export default class LintPlugin implements IHeftTaskPlugin { // Disable linting in watch mode. Some lint rules require the context of multiple files, which // may not be available in watch mode. if (!taskSession.parameters.watch) { + let fix: boolean = + pluginOptions?.alwaysFix || taskSession.parameters.getFlagParameter(FIX_PARAMETER_NAME).value; + if (fix && taskSession.parameters.production) { + // Write this as a standard output message since we don't want to throw errors when running in + // production mode and "alwaysFix" is specified in the plugin options + taskSession.logger.terminal.writeLine( + 'Fix mode has been disabled since Heft is running in production mode' + ); + fix = false; + } + // Use the changed files hook to kick off linting asynchronously taskSession.requestAccessToPluginByName( '@rushstack/heft-typescript-plugin', @@ -68,9 +79,7 @@ export default class LintPlugin implements IHeftTaskPlugin { const lintingPromise: Promise = this._lintAsync({ taskSession, heftConfiguration, - fix: - pluginOptions?.alwaysFix || - taskSession.parameters.getFlagParameter(FIX_PARAMETER_NAME).value, + fix, tsProgram: changedFilesHookOptions.program as IExtendedProgram, changedFiles: changedFilesHookOptions.changedFiles as ReadonlySet }); @@ -96,7 +105,7 @@ export default class LintPlugin implements IHeftTaskPlugin { warningPrinted = true; // Warn since don't run the linters when in watch mode. - taskSession.logger.terminal.writeWarningLine("Linting isn't currently supported in watch mode."); + taskSession.logger.terminal.writeWarningLine("Linting isn't currently supported in watch mode"); } else { await Promise.all(this._lintingPromises); } diff --git a/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json b/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json index 3e5ccdd14d4..d48c0d3788e 100644 --- a/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json +++ b/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json @@ -9,7 +9,7 @@ "properties": { "alwaysFix": { "title": "Always Fix", - "description": "If set to true, fix all encountered rule violations where the violated rule provides a fixer, regardless of if the \"--fix\" command-line argument is provided.", + "description": "If set to true, fix all encountered rule violations where the violated rule provides a fixer, regardless of if the \"--fix\" command-line argument is provided. When running in production mode, fixes will be disabled regardless of this setting.", "type": "boolean" } } diff --git a/libraries/api-extractor-model/src/items/ApiPropertyItem.ts b/libraries/api-extractor-model/src/items/ApiPropertyItem.ts index 358fd1de92e..fbe7d94aaea 100644 --- a/libraries/api-extractor-model/src/items/ApiPropertyItem.ts +++ b/libraries/api-extractor-model/src/items/ApiPropertyItem.ts @@ -2,11 +2,7 @@ // See LICENSE in the project root for license information. import type { Excerpt, IExcerptTokenRange } from '../mixins/Excerpt'; -import { - type IApiDeclaredItemOptions, - ApiDeclaredItem, - type IApiDeclaredItemJson -} from '../items/ApiDeclaredItem'; +import { type IApiDeclaredItemOptions, ApiDeclaredItem, type IApiDeclaredItemJson } from './ApiDeclaredItem'; import { ApiReleaseTagMixin, type IApiReleaseTagMixinOptions } from '../mixins/ApiReleaseTagMixin'; import { type IApiNameMixinOptions, ApiNameMixin } from '../mixins/ApiNameMixin'; import type { DeserializerContext } from '../model/DeserializerContext'; diff --git a/libraries/load-themed-styles/src/test/index.test.ts b/libraries/load-themed-styles/src/test/index.test.ts index d76a90ea8c1..000da16af8f 100644 --- a/libraries/load-themed-styles/src/test/index.test.ts +++ b/libraries/load-themed-styles/src/test/index.test.ts @@ -8,7 +8,7 @@ import { loadStyles, configureLoadStyles, type IThemingInstruction -} from './../index'; +} from '../index'; describe('detokenize', () => { it('handles colors', () => { diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index f3fa0cbd6e4..fdf86ae1859 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -17,7 +17,7 @@ import { import { LookupByPath } from '@rushstack/lookup-by-path'; import { trueCasePathSync } from 'true-case-path'; -import { Rush } from '../api/Rush'; +import { Rush } from './Rush'; import { RushConfigurationProject, type IRushConfigurationProjectJson } from './RushConfigurationProject'; import { RushConstants } from '../logic/RushConstants'; import { ApprovedPackagesPolicy } from './ApprovedPackagesPolicy'; diff --git a/libraries/rush-lib/src/api/RushConfigurationProject.ts b/libraries/rush-lib/src/api/RushConfigurationProject.ts index 90b2fe0ca70..21e7062807a 100644 --- a/libraries/rush-lib/src/api/RushConfigurationProject.ts +++ b/libraries/rush-lib/src/api/RushConfigurationProject.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as semver from 'semver'; import { type IPackageJson, FileSystem, FileConstants } from '@rushstack/node-core-library'; -import type { RushConfiguration } from '../api/RushConfiguration'; +import type { RushConfiguration } from './RushConfiguration'; import type { VersionPolicy, LockStepVersionPolicy } from './VersionPolicy'; import type { PackageJsonEditor } from './PackageJsonEditor'; import { RushConstants } from '../logic/RushConstants'; diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index c6301dfd95f..3f26ae3aadf 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -9,7 +9,7 @@ import { Colorize } from '@rushstack/terminal'; import type { RushConfiguration } from '../../api/RushConfiguration'; import { EventHooksManager } from '../../logic/EventHooksManager'; -import { RushCommandLineParser } from './../RushCommandLineParser'; +import { RushCommandLineParser } from '../RushCommandLineParser'; import { Utilities } from '../../utilities/Utilities'; import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; import type { RushSession } from '../../pluginFramework/RushSession'; diff --git a/libraries/rush-lib/src/logic/PurgeManager.ts b/libraries/rush-lib/src/logic/PurgeManager.ts index b99773ebe6a..423d1ca388d 100644 --- a/libraries/rush-lib/src/logic/PurgeManager.ts +++ b/libraries/rush-lib/src/logic/PurgeManager.ts @@ -6,7 +6,7 @@ import { Colorize } from '@rushstack/terminal'; import { AsyncRecycler } from '../utilities/AsyncRecycler'; import type { RushConfiguration } from '../api/RushConfiguration'; -import { RushConstants } from '../logic/RushConstants'; +import { RushConstants } from './RushConstants'; import type { RushGlobalFolder } from '../api/RushGlobalFolder'; /** diff --git a/libraries/rush-lib/src/logic/SetupChecks.ts b/libraries/rush-lib/src/logic/SetupChecks.ts index 629254cc557..f85a2386664 100644 --- a/libraries/rush-lib/src/logic/SetupChecks.ts +++ b/libraries/rush-lib/src/logic/SetupChecks.ts @@ -7,7 +7,7 @@ import { FileSystem, AlreadyReportedError } from '@rushstack/node-core-library'; import { Colorize, PrintUtilities } from '@rushstack/terminal'; import type { RushConfiguration } from '../api/RushConfiguration'; -import { RushConstants } from '../logic/RushConstants'; +import { RushConstants } from './RushConstants'; // Refuses to run at all if the PNPM version is older than this, because there // are known bugs or missing features in earlier releases. diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index d3594158c00..2ec3b4aec7a 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -30,7 +30,7 @@ import { import { ApprovedPackagesChecker } from '../ApprovedPackagesChecker'; import type { AsyncRecycler } from '../../utilities/AsyncRecycler'; -import type { BaseShrinkwrapFile } from '../base/BaseShrinkwrapFile'; +import type { BaseShrinkwrapFile } from './BaseShrinkwrapFile'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import { Git } from '../Git'; import { diff --git a/libraries/rush-lib/src/logic/base/BaseShrinkwrapFile.ts b/libraries/rush-lib/src/logic/base/BaseShrinkwrapFile.ts index 04b07fd1981..928d66116a1 100644 --- a/libraries/rush-lib/src/logic/base/BaseShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/base/BaseShrinkwrapFile.ts @@ -4,7 +4,7 @@ import * as semver from 'semver'; import { Colorize, type ITerminal } from '@rushstack/terminal'; -import { RushConstants } from '../../logic/RushConstants'; +import { RushConstants } from '../RushConstants'; import { type DependencySpecifier, DependencySpecifierType } from '../DependencySpecifier'; import type { IShrinkwrapFilePolicyValidatorOptions } from '../policy/ShrinkwrapFilePolicy'; import type { RushConfiguration } from '../../api/RushConfiguration'; diff --git a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts index 87b3bd79646..717b1e0cb1e 100644 --- a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts @@ -18,10 +18,10 @@ import { Colorize, PrintUtilities } from '@rushstack/terminal'; import { BaseInstallManager } from '../base/BaseInstallManager'; import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; -import type { BaseShrinkwrapFile } from '../../logic/base/BaseShrinkwrapFile'; -import type { IRushTempPackageJson } from '../../logic/base/BasePackage'; +import type { BaseShrinkwrapFile } from '../base/BaseShrinkwrapFile'; +import type { IRushTempPackageJson } from '../base/BasePackage'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import { RushConstants } from '../../logic/RushConstants'; +import { RushConstants } from '../RushConstants'; import { Stopwatch } from '../../utilities/Stopwatch'; import { Utilities } from '../../utilities/Utilities'; import { diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index c505cebac59..5098f48e4d1 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -15,7 +15,7 @@ import { import { BaseInstallManager } from '../base/BaseInstallManager'; import type { IInstallManagerOptions } from '../base/BaseInstallManagerTypes'; -import type { BaseShrinkwrapFile } from '../../logic/base/BaseShrinkwrapFile'; +import type { BaseShrinkwrapFile } from '../base/BaseShrinkwrapFile'; import { DependencySpecifier, DependencySpecifierType } from '../DependencySpecifier'; import { type PackageJsonEditor, @@ -24,7 +24,7 @@ import { } from '../../api/PackageJsonEditor'; import { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; -import { RushConstants } from '../../logic/RushConstants'; +import { RushConstants } from '../RushConstants'; import { Utilities } from '../../utilities/Utilities'; import { InstallHelpers } from './InstallHelpers'; import type { CommonVersionsConfiguration } from '../../api/CommonVersionsConfiguration'; diff --git a/libraries/rush-lib/src/logic/npm/NpmLinkManager.ts b/libraries/rush-lib/src/logic/npm/NpmLinkManager.ts index ec3af9cd779..305122d957f 100644 --- a/libraries/rush-lib/src/logic/npm/NpmLinkManager.ts +++ b/libraries/rush-lib/src/logic/npm/NpmLinkManager.ts @@ -8,7 +8,7 @@ import readPackageTree from 'read-package-tree'; import { FileSystem, FileConstants, LegacyAdapters } from '@rushstack/node-core-library'; import { Colorize } from '@rushstack/terminal'; -import { RushConstants } from '../../logic/RushConstants'; +import { RushConstants } from '../RushConstants'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { Utilities } from '../../utilities/Utilities'; import { NpmPackage, type IResolveOrCreateResult, PackageDependencyKind } from './NpmPackage'; diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmLinkManager.ts b/libraries/rush-lib/src/logic/pnpm/PnpmLinkManager.ts index 25c08e573a4..3b627490037 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmLinkManager.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmLinkManager.ts @@ -18,7 +18,7 @@ import { Colorize } from '@rushstack/terminal'; import { BaseLinkManager } from '../base/BaseLinkManager'; import { BasePackage } from '../base/BasePackage'; -import { RushConstants } from '../../logic/RushConstants'; +import { RushConstants } from '../RushConstants'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { PnpmShrinkwrapFile, diff --git a/webpack/loader-load-themed-styles/src/test/LoadThemedStylesLoader.test.ts b/webpack/loader-load-themed-styles/src/test/LoadThemedStylesLoader.test.ts index 011f1d34701..c0c687b572e 100644 --- a/webpack/loader-load-themed-styles/src/test/LoadThemedStylesLoader.test.ts +++ b/webpack/loader-load-themed-styles/src/test/LoadThemedStylesLoader.test.ts @@ -3,7 +3,7 @@ import webpack = require('webpack'); -import { LoadThemedStylesLoader } from './../LoadThemedStylesLoader'; +import { LoadThemedStylesLoader } from '../LoadThemedStylesLoader'; import LoadThemedStylesMock = require('./testData/LoadThemedStylesMock'); function wrapResult(loaderResult: string): string {