Skip to content

Commit 0381a02

Browse files
authored
[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
1 parent 01173c3 commit 0381a02

39 files changed

+1219
-32
lines changed

apps/heft/src/cli/HeftActionRunner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { HeftParameterManager } from '../pluginFramework/HeftParameterManager';
2929
import { TaskOperationRunner } from '../operations/runners/TaskOperationRunner';
3030
import { PhaseOperationRunner } from '../operations/runners/PhaseOperationRunner';
3131
import type { HeftPhase } from '../pluginFramework/HeftPhase';
32-
import type { IHeftAction, IHeftActionOptions } from '../cli/actions/IHeftAction';
32+
import type { IHeftAction, IHeftActionOptions } from './actions/IHeftAction';
3333
import type {
3434
IHeftLifecycleCleanHookOptions,
3535
IHeftLifecycleSession,

apps/heft/src/pluginFramework/HeftTask.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import type {
1313
IHeftConfigurationJsonTaskSpecifier,
1414
IHeftConfigurationJsonPluginSpecifier
1515
} from '../utilities/CoreConfigFiles';
16-
import type { IHeftTaskPlugin } from '../pluginFramework/IHeftPlugin';
17-
import type { IScopedLogger } from '../pluginFramework/logging/ScopedLogger';
16+
import type { IHeftTaskPlugin } from './IHeftPlugin';
17+
import type { IScopedLogger } from './logging/ScopedLogger';
1818

1919
const RESERVED_TASK_NAMES: Set<string> = new Set(['clean']);
2020

apps/lockfile-explorer-web/src/store/hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// See LICENSE in the project root for license information.
33

44
import { type TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
5-
import type { RootState, AppDispatch } from './';
5+
import type { RootState, AppDispatch } from '.';
66

77
// Use throughout your app instead of plain `useDispatch` and `useSelector`
88
export const useAppDispatch: () => AppDispatch = useDispatch;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/api-extractor-model",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/api-extractor-model"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/load-themed-styles",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/load-themed-styles"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/loader-load-themed-styles",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/loader-load-themed-styles"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/eslint-plugin",
5+
"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",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/eslint-plugin"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-lint-plugin",
5+
"comment": "Unintrusively disable \"--fix\" mode when running in \"--production\" mode",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-lint-plugin"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@rushstack/heft"
10+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as path from 'path';
5+
import { ESLintUtils, TSESTree, type TSESLint } from '@typescript-eslint/utils';
6+
import type { Program } from 'typescript';
7+
8+
export interface IParsedImportSpecifier {
9+
loader?: string;
10+
importTarget: string;
11+
loaderOptions?: string;
12+
}
13+
14+
// Regex to parse out the import target from the specifier. Expected formats are:
15+
// - '<target>'
16+
// - '<loader>!<target>'
17+
// - '<target>?<loader-options>'
18+
// - '<loader>!<target>?<loader-options>'
19+
const LOADER_CAPTURE_GROUP: 'loader' = 'loader';
20+
const IMPORT_TARGET_CAPTURE_GROUP: 'importTarget' = 'importTarget';
21+
const LOADER_OPTIONS_CAPTURE_GROUP: 'loaderOptions' = 'loaderOptions';
22+
// eslint-disable-next-line @rushstack/security/no-unsafe-regexp
23+
const SPECIFIER_REGEX: RegExp = new RegExp(
24+
`^((?<${LOADER_CAPTURE_GROUP}>(!|-!|!!).+)!)?` +
25+
`(?<${IMPORT_TARGET_CAPTURE_GROUP}>[^!?]+)` +
26+
`(\\?(?<${LOADER_OPTIONS_CAPTURE_GROUP}>.*))?$`
27+
);
28+
29+
export function getFilePathFromContext(context: TSESLint.RuleContext<string, unknown[]>): string {
30+
return context.physicalFilename || context.filename;
31+
}
32+
33+
export function getRootDirectoryFromContext(
34+
context: TSESLint.RuleContext<string, unknown[]>
35+
): string | undefined {
36+
let rootDirectory: string | undefined;
37+
try {
38+
// First attempt to get the root directory from the tsconfig baseUrl, then the program current directory
39+
const program: Program | null | undefined = (
40+
context.sourceCode?.parserServices ?? ESLintUtils.getParserServices(context)
41+
).program;
42+
rootDirectory = program?.getCompilerOptions().baseUrl ?? program?.getCurrentDirectory();
43+
} catch {
44+
// Ignore the error if we cannot retrieve a TS program
45+
}
46+
47+
// Fall back to the parserOptions.tsconfigRootDir if available, otherwise the eslint working directory
48+
if (!rootDirectory) {
49+
rootDirectory = context.parserOptions?.tsconfigRootDir ?? context.getCwd?.();
50+
}
51+
52+
return rootDirectory;
53+
}
54+
55+
export function parseImportSpecifierFromExpression(
56+
importExpression: TSESTree.Expression
57+
): IParsedImportSpecifier | undefined {
58+
if (
59+
!importExpression ||
60+
importExpression.type !== TSESTree.AST_NODE_TYPES.Literal ||
61+
typeof importExpression.value !== 'string'
62+
) {
63+
// Can't determine the path of the import target, return
64+
return undefined;
65+
}
66+
67+
// Extract the target of the import, stripping out webpack loaders and query strings. The regex will
68+
// also ensure that the import target is a relative path.
69+
const specifierMatch: RegExpMatchArray | null = importExpression.value.match(SPECIFIER_REGEX);
70+
if (!specifierMatch?.groups) {
71+
// Can't determine the path of the import target, return
72+
return undefined;
73+
}
74+
75+
const loader: string | undefined = specifierMatch.groups[LOADER_CAPTURE_GROUP];
76+
const importTarget: string = specifierMatch.groups[IMPORT_TARGET_CAPTURE_GROUP];
77+
const loaderOptions: string | undefined = specifierMatch.groups[LOADER_OPTIONS_CAPTURE_GROUP];
78+
return { loader, importTarget, loaderOptions };
79+
}
80+
81+
export function serializeImportSpecifier(parsedImportPath: IParsedImportSpecifier): string {
82+
const { loader, importTarget, loaderOptions } = parsedImportPath;
83+
return `${loader ? `${loader}!` : ''}${importTarget}${loaderOptions ? `?${loaderOptions}` : ''}`;
84+
}
85+
86+
export function getImportPathFromExpression(
87+
importExpression: TSESTree.Expression,
88+
relativeImportsOnly: boolean = true
89+
): string | undefined {
90+
const parsedImportSpecifier: IParsedImportSpecifier | undefined =
91+
parseImportSpecifierFromExpression(importExpression);
92+
if (
93+
!parsedImportSpecifier ||
94+
(relativeImportsOnly && !parsedImportSpecifier.importTarget.startsWith('.'))
95+
) {
96+
// The import target isn't a path, return
97+
return undefined;
98+
}
99+
return parsedImportSpecifier?.importTarget;
100+
}
101+
102+
export function getImportAbsolutePathFromExpression(
103+
context: TSESLint.RuleContext<string, unknown[]>,
104+
importExpression: TSESTree.Expression,
105+
relativeImportsOnly: boolean = true
106+
): string | undefined {
107+
const importPath: string | undefined = getImportPathFromExpression(importExpression, relativeImportsOnly);
108+
if (importPath === undefined) {
109+
// Can't determine the absolute path of the import target, return
110+
return undefined;
111+
}
112+
113+
const filePath: string = getFilePathFromContext(context);
114+
const fileDirectory: string = path.dirname(filePath);
115+
116+
// Combine the import path with the absolute path of the file parent directory to get the
117+
// absolute path of the import target
118+
return path.resolve(fileDirectory, importPath);
119+
}

eslint/eslint-plugin/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
import { TSESLint } from '@typescript-eslint/utils';
55

66
import { hoistJestMock } from './hoist-jest-mock';
7+
import { noBackslashImportsRule } from './no-backslash-imports';
8+
import { noExternalLocalImportsRule } from './no-external-local-imports';
79
import { noNewNullRule } from './no-new-null';
810
import { noNullRule } from './no-null';
11+
import { noTransitiveDependencyImportsRule } from './no-transitive-dependency-imports';
912
import { noUntypedUnderscoreRule } from './no-untyped-underscore';
13+
import { normalizedImportsRule } from './normalized-imports';
1014
import { typedefVar } from './typedef-var';
1115

1216
interface IPlugin {
@@ -18,15 +22,27 @@ const plugin: IPlugin = {
1822
// Full name: "@rushstack/hoist-jest-mock"
1923
'hoist-jest-mock': hoistJestMock,
2024

25+
// Full name: "@rushstack/no-backslash-imports"
26+
'no-backslash-imports': noBackslashImportsRule,
27+
28+
// Full name: "@rushstack/no-external-local-imports"
29+
'no-external-local-imports': noExternalLocalImportsRule,
30+
2131
// Full name: "@rushstack/no-new-null"
2232
'no-new-null': noNewNullRule,
2333

2434
// Full name: "@rushstack/no-null"
2535
'no-null': noNullRule,
2636

37+
// Full name: "@rushstack/no-transitive-dependency-imports"
38+
'no-transitive-dependency-imports': noTransitiveDependencyImportsRule,
39+
2740
// Full name: "@rushstack/no-untyped-underscore"
2841
'no-untyped-underscore': noUntypedUnderscoreRule,
2942

43+
// Full name: "@rushstack/normalized-imports"
44+
'normalized-imports': normalizedImportsRule,
45+
3046
// Full name: "@rushstack/typedef-var"
3147
'typedef-var': typedefVar
3248
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { TSESTree, TSESLint } from '@typescript-eslint/utils';
5+
import {
6+
parseImportSpecifierFromExpression,
7+
serializeImportSpecifier,
8+
type IParsedImportSpecifier
9+
} from './LintUtilities';
10+
11+
export const MESSAGE_ID: 'no-backslash-imports' = 'no-backslash-imports';
12+
type RuleModule = TSESLint.RuleModule<typeof MESSAGE_ID, []>;
13+
type RuleContext = TSESLint.RuleContext<typeof MESSAGE_ID, []>;
14+
15+
export const noBackslashImportsRule: RuleModule = {
16+
defaultOptions: [],
17+
meta: {
18+
type: 'problem',
19+
messages: {
20+
[MESSAGE_ID]: 'The specified import target path contains backslashes.'
21+
},
22+
schema: [],
23+
docs: {
24+
description: 'Prevents imports using paths that use backslashes',
25+
url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin'
26+
},
27+
fixable: 'code'
28+
},
29+
create: (context: RuleContext) => {
30+
const checkImportExpression: (importExpression: TSESTree.Expression | null) => void = (
31+
importExpression: TSESTree.Expression | null
32+
) => {
33+
if (!importExpression) {
34+
// Can't validate, return
35+
return;
36+
}
37+
38+
// Determine the target file path and find the most direct relative path from the source file
39+
const importSpecifier: IParsedImportSpecifier | undefined =
40+
parseImportSpecifierFromExpression(importExpression);
41+
if (importSpecifier === undefined) {
42+
// Can't validate, return
43+
return;
44+
}
45+
46+
// Check if the import path contains backslashes. If it does, suggest a fix to replace them with forward
47+
// slashes.
48+
const { importTarget } = importSpecifier;
49+
if (importTarget.includes('\\')) {
50+
context.report({
51+
node: importExpression,
52+
messageId: MESSAGE_ID,
53+
fix: (fixer: TSESLint.RuleFixer) => {
54+
const normalizedSpecifier: IParsedImportSpecifier = {
55+
...importSpecifier,
56+
importTarget: importTarget.replace(/\\/g, '/')
57+
};
58+
return fixer.replaceText(importExpression, `'${serializeImportSpecifier(normalizedSpecifier)}'`);
59+
}
60+
});
61+
}
62+
};
63+
64+
return {
65+
ImportDeclaration: (node: TSESTree.ImportDeclaration) => checkImportExpression(node.source),
66+
ImportExpression: (node: TSESTree.ImportExpression) => checkImportExpression(node.source),
67+
ExportAllDeclaration: (node: TSESTree.ExportAllDeclaration) => checkImportExpression(node.source),
68+
ExportNamedDeclaration: (node: TSESTree.ExportNamedDeclaration) => checkImportExpression(node.source)
69+
};
70+
}
71+
};

0 commit comments

Comments
 (0)