| // Copyright 2020 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @file Rule to check ES import usage |
| * @author Tim van der Lippe |
| */ |
| |
| import type {TSESLint, TSESTree} from '@typescript-eslint/utils'; |
| import path from 'node:path'; |
| |
| import {createRule} from './utils/ruleCreator.ts'; |
| import {isStarAsImportSpecifier} from './utils/treeHelpers.ts'; |
| // Define types based on TSESTree |
| type ImportDeclaration = TSESTree.ImportDeclaration; |
| type ExportNamedDeclaration = TSESTree.ExportNamedDeclaration; |
| type ImportSpecifier = TSESTree.ImportSpecifier; |
| type ImportDefaultSpecifier = TSESTree.ImportDefaultSpecifier; |
| type ImportNamespaceSpecifier = TSESTree.ImportNamespaceSpecifier; |
| type RuleContext = TSESLint.RuleContext<MessageIds, []>; // Define MessageIds below |
| type RuleFixer = TSESLint.RuleFixer; |
| |
| // Define MessageIds used in the rule |
| type MessageIds = 'doubleSlashInImportPath'|'missingExtension'|'invalidRelativeUrl'|'incorrectSameNamespaceImportNamed'| |
| 'incorrectSameNamespaceImportStar'|'crossNamespaceImport'|'crossNamespaceImportLs'|'crossNamespaceImportThirdParty'| |
| 'incorrectSameNamespaceTestImport'; |
| |
| const FRONT_END_DIRECTORY = path.join( |
| // @ts-expect-error |
| import.meta.dirname, |
| '..', |
| '..', |
| '..', |
| 'front_end', |
| ); |
| const THIRD_PARTY_DIRECTORY = path.join(FRONT_END_DIRECTORY, 'third_party'); |
| const INSPECTOR_OVERLAY_DIRECTORY = path.join( |
| // @ts-expect-error |
| import.meta.dirname, |
| '..', |
| '..', |
| '..', |
| 'front_end', |
| 'inspector_overlay', |
| ); |
| const COMPONENT_DOCS_DIRECTORY = path.join( |
| FRONT_END_DIRECTORY, |
| 'ui', |
| 'components', |
| 'docs', |
| ); |
| |
| const CROSS_NAMESPACE_MESSAGE = |
| 'Incorrect cross-namespace import: "{{importPathForErrorMessage}}". Use "import * as Namespace from \'../namespace/namespace.js\';" instead.'; |
| |
| // ------------------------------------------------------------------------------ |
| // Rule Definition |
| // ------------------------------------------------------------------------------ |
| |
| function isSideEffectImportSpecifier( |
| specifiers: Array<ImportSpecifier|ImportDefaultSpecifier|ImportNamespaceSpecifier>): boolean { |
| return specifiers.length === 0; |
| } |
| |
| function isModuleEntrypoint(fileName: string): boolean { |
| const fileNameWithoutExtension = path.basename(fileName).replace(path.extname(fileName), ''); |
| const directoryName = path.basename(path.dirname(fileName)); |
| return (directoryName === fileNameWithoutExtension || `${directoryName}-testing` === fileNameWithoutExtension); |
| } |
| |
| function computeTopLevelFolder(fileName: string): string { |
| const namespaceName = path.relative(FRONT_END_DIRECTORY, fileName); |
| return namespaceName.substring(0, namespaceName.indexOf(path.sep)); |
| } |
| |
| function checkImportExtension( |
| importPath: string, |
| importPathForErrorMessage: string, |
| context: RuleContext, |
| node: ImportDeclaration|ExportNamedDeclaration, // Node can be Import or Export |
| ): void { |
| // detect import * as fs from 'fs'; |
| if (!importPath.startsWith('.')) { |
| return; |
| } |
| |
| if (!importPath.endsWith('.js') && !importPath.endsWith('.mjs')) { |
| context.report({ |
| node, |
| messageId: 'missingExtension', |
| data: { |
| importPathForErrorMessage, |
| }, |
| fix(fixer: RuleFixer) { |
| if (node.source) { |
| return fixer.replaceText( |
| node.source, |
| `'${importPathForErrorMessage}.js'`, |
| ); |
| } |
| return null; |
| }, |
| }); |
| } |
| } |
| |
| function nodeSpecifiersSpecialImportsOnly( |
| specifiers: Array<ImportSpecifier|ImportDefaultSpecifier|ImportNamespaceSpecifier>): boolean { |
| if (specifiers.length !== 1) { |
| return false; |
| } |
| const firstSpecifier = specifiers[0]; |
| |
| return ( |
| firstSpecifier.type === 'ImportSpecifier' && firstSpecifier.imported.type === 'Identifier' && |
| ['ls', 'assertNotNullOrUndefined'].includes(firstSpecifier.imported.name)); |
| } |
| |
| function reportIncorrectSameNamespaceTestImport( |
| context: RuleContext, |
| node: ImportDeclaration, |
| importingDirectoryName: string, |
| importPathForErrorMessage: string, |
| ): void { |
| const namespaceNameForErrorMessage = |
| importingDirectoryName.substring(0, 1).toUpperCase() + importingDirectoryName.substring(1); |
| const namespaceFilenameForErrorMessage = importingDirectoryName; |
| context.report({ |
| node, |
| messageId: 'incorrectSameNamespaceTestImport', |
| data: { |
| importPathForErrorMessage, |
| namespaceNameForErrorMessage, |
| namespaceFilenameForErrorMessage, |
| }, |
| }); |
| } |
| |
| function checkStarImport( |
| context: RuleContext, |
| node: ImportDeclaration, |
| importPath: string, |
| importPathForErrorMessage: string, |
| importingFileName: string, |
| exportingFileName: string, |
| ): void { |
| if (isModuleEntrypoint(importingFileName)) { |
| return; |
| } |
| |
| if (importingFileName.startsWith(COMPONENT_DOCS_DIRECTORY) && |
| importPath.includes([path.sep, 'testing', path.sep].join(''))) { |
| return; |
| } |
| |
| // The generated code is typically part of a different folder. Therefore, |
| // it is allowed to directly import these files, as they are only |
| // imported in 1 place at a time. |
| if (computeTopLevelFolder(exportingFileName) === 'generated') { |
| return; |
| } |
| |
| const isSameFolder = path.dirname(importingFileName) === path.dirname(exportingFileName); |
| |
| const invalidSameFolderUsage = isSameFolder && isModuleEntrypoint(exportingFileName); |
| const invalidCrossFolderUsage = !isSameFolder && !isModuleEntrypoint(exportingFileName); |
| |
| // Unit tests must import from the entry points even for same-namespace |
| // imports, as we otherwise break the module system (in Release builds). |
| if (isSameFolder && importingFileName.endsWith('.test.ts') && !isModuleEntrypoint(exportingFileName)) { |
| const importingDirectoryName = path.basename( |
| path.dirname(importingFileName), |
| ); |
| |
| if (importingDirectoryName === 'testing') { |
| // Special case of Foo.test.ts for a helper Foo.ts. |
| return; |
| } |
| |
| reportIncorrectSameNamespaceTestImport( |
| context, |
| node, |
| importPathForErrorMessage, |
| importingDirectoryName, |
| ); |
| return; |
| } |
| if (invalidSameFolderUsage) { |
| // Meta files import their entrypoints and are considered separate entrypoints. |
| // Additionally, any file ending with `-entrypoint.ts` is considered an entrypoint |
| // as well. Therefore, they are allowed to import using a same-namespace star-import. |
| // For `.test.ts` files we also need to use the namespace import syntax, to access |
| // the module itself, so we need to allow this as well. |
| const importingFileIsEntrypointOrTest = importingFileName.endsWith('-entrypoint.ts') || |
| importingFileName.endsWith('-meta.ts') || importingFileName.endsWith('.test.ts'); |
| |
| if (!importingFileIsEntrypointOrTest) { |
| context.report({ |
| node, |
| messageId: 'incorrectSameNamespaceImportStar', |
| data: { |
| importPathForErrorMessage, |
| }, |
| }); |
| } |
| } |
| |
| if (invalidCrossFolderUsage) { |
| context.report({ |
| node, |
| messageId: 'crossNamespaceImport', |
| data: {importPathForErrorMessage}, |
| }); |
| } |
| } |
| |
| export default createRule<[], MessageIds>({ |
| name: 'es-modules-import', |
| meta: { |
| type: 'problem', |
| messages: { |
| // Define messages corresponding to MessageIds |
| doubleSlashInImportPath: 'Double slash in import path: "{{importPathForErrorMessage}}"', |
| missingExtension: 'Missing file extension for import "{{importPathForErrorMessage}}"', |
| invalidRelativeUrl: 'Invalid relative URL import. An import should start with either "../" or "./".', |
| incorrectSameNamespaceImportNamed: |
| 'Incorrect same-namespace import: "{{importPathForErrorMessage}}". Use "import * as File from \'./File.js\';" instead.', |
| incorrectSameNamespaceImportStar: |
| 'Incorrect same-namespace import: "{{importPathForErrorMessage}}". Use "import { Symbol } from \'./relative-file.js\';" instead.', |
| crossNamespaceImport: CROSS_NAMESPACE_MESSAGE, |
| crossNamespaceImportLs: |
| CROSS_NAMESPACE_MESSAGE + ' You may only import common/ls.js directly from TypeScript source files.', |
| crossNamespaceImportThirdParty: CROSS_NAMESPACE_MESSAGE + |
| ' If the third_party dependency does not expose a single entrypoint, update es_modules_import.js to make it exempt.', |
| incorrectSameNamespaceTestImport: |
| 'Incorrect same-namespace import: "{{importPathForErrorMessage}}". Use "import * as {{namespaceNameForErrorMessage}} from \'./{{namespaceFilenameForErrorMessage}}.js\';" instead.', |
| }, |
| docs: { |
| description: 'check ES import usage', |
| category: 'Possible Errors', |
| }, |
| fixable: 'code', |
| schema: [], // no options |
| }, |
| defaultOptions: [], // Add defaultOptions |
| create: function(context: RuleContext) { |
| const filename = context.filename; |
| // Ensure filename is resolved to an absolute path if it's not already |
| const importingFileName = path.resolve(filename); |
| |
| return { |
| ExportNamedDeclaration(node) { |
| // Any export in a file is called an `ExportNamedDeclaration`, but |
| // only directly-exporting-from-import declarations have the |
| // `node.source` set. |
| if (!node.source) { |
| return; |
| } |
| const value = node.source.value; |
| const importPath = path.normalize(value); |
| const importPathForErrorMessage = value.replace(/\\/g, '/'); |
| checkImportExtension( |
| importPath, |
| importPathForErrorMessage, |
| context, |
| node, |
| ); |
| }, |
| ImportDeclaration(node) { |
| const value = node.source.value; |
| |
| if (value.includes('//')) { |
| context.report({ |
| node, |
| messageId: 'doubleSlashInImportPath', |
| data: { |
| importPathForErrorMessage: value, |
| }, |
| fix(fixer: RuleFixer) { |
| const fixedValue = value.replaceAll('//', '/'); |
| // Replace the original import string with the fixed one. We need |
| // the extra quotes around the value to ensure we produce valid |
| // JS - else it would end up as `import X from ../some/path.js` |
| return fixer.replaceText(node.source, `'${fixedValue}'`); |
| }, |
| }); |
| } |
| |
| // We don't check for bare imports (e.g. import * as fs from 'fs') |
| if (!value.includes('/')) { |
| return; |
| } |
| |
| const importPath = path.normalize(value); |
| const importPathForErrorMessage = value.replace(/\\/g, '/'); |
| |
| checkImportExtension( |
| value, |
| importPathForErrorMessage, |
| context, |
| node, |
| ); |
| |
| // Type imports are unrestricted |
| if (node.importKind === 'type') { |
| // `import type ... from ...` syntax |
| return; |
| } |
| // Check specifiers for `import {type T} from ...` |
| if (node.specifiers.every(spec => spec.type === 'ImportSpecifier' && spec.importKind === 'type')) { |
| return; |
| } |
| |
| // Accidental relative URL: |
| // e.g.: import * as Root from 'front_end/root/root.js'; |
| // |
| // Should ignore named imports import * as fs from 'fs'; |
| // |
| // Don't use `importPath` here, as `path.normalize` removes |
| // the `./` from same-folder import paths. Use original `value`. |
| if (!value.startsWith('.') && !/^[\w\-_]+$/.test(value)) { |
| context.report({ |
| node, |
| messageId: 'invalidRelativeUrl', // Use messageId |
| }); |
| } |
| |
| // the Module import rules do not apply within: |
| // 1. inspector_overlay |
| // 2. front_end/third_party |
| if (importingFileName.startsWith(INSPECTOR_OVERLAY_DIRECTORY) || |
| importingFileName.startsWith(THIRD_PARTY_DIRECTORY)) { |
| return; |
| } |
| |
| if (isSideEffectImportSpecifier(node.specifiers)) { |
| return; |
| } |
| |
| // We explicitly allow destructuring imports from 'lit/lit.js'. |
| if (importPath.endsWith(path.join('lit', 'lit.js'))) { |
| // If we try to import third_party report it as an error |
| // We should import from "../ui/lit/lit.js" |
| if (importPath.includes('third_party') && !filename.includes('ui/lit/')) { |
| context.report({ |
| node, |
| messageId: 'crossNamespaceImportThirdParty', |
| data: { |
| importPathForErrorMessage, |
| }, |
| }); |
| return; |
| } |
| return; |
| } |
| |
| const currentFileDirectory = path.dirname(filename); |
| const resolvedAbsoluteImportPath = path.resolve(currentFileDirectory, importPath); |
| // We explicitly allow destructuring imports from 'front_end/ui/kit/kit.js'. |
| if (resolvedAbsoluteImportPath.endsWith(path.join('front_end', 'ui', 'kit', 'kit.js'))) { |
| return; |
| } |
| |
| if (importPathForErrorMessage.endsWith('platform/platform.js') && |
| nodeSpecifiersSpecialImportsOnly(node.specifiers)) { |
| /* We allow direct importing of the ls and assertNotNull utility as it's so frequently used. */ |
| return; |
| } |
| |
| const exportingFileName = path.resolve( |
| path.dirname(importingFileName), |
| importPath, |
| ); |
| |
| if (isStarAsImportSpecifier(node.specifiers)) { |
| // Pass absolute paths to checkStarImport |
| checkStarImport( |
| context, |
| node, |
| importPath, |
| importPathForErrorMessage, |
| importingFileName, |
| exportingFileName, |
| ); |
| } else if (computeTopLevelFolder(importingFileName) !== computeTopLevelFolder(exportingFileName)) { |
| // Check if exportingFileName is actually under FRONT_END_DIRECTORY before comparing top level folders |
| if (!path.relative(FRONT_END_DIRECTORY, exportingFileName).startsWith('..')) { |
| if (importingFileName.endsWith('.test.ts') && |
| importPath.includes([path.sep, 'testing', path.sep].join(''))) { |
| /** Within test files we allow the direct import of test helpers.*/ |
| return; |
| } |
| |
| let messageId: MessageIds = 'crossNamespaceImport'; |
| |
| if (value.endsWith('common/ls.js')) { |
| messageId = 'crossNamespaceImportLs'; |
| } else if (importPath.includes('third_party')) { |
| messageId = 'crossNamespaceImportThirdParty'; |
| } |
| |
| context.report({ |
| node, |
| messageId, |
| data: { |
| importPathForErrorMessage, |
| }, |
| }); |
| } |
| } else if (isModuleEntrypoint(importingFileName)) { |
| if (importingFileName.includes( |
| ['testing', 'test_setup.ts'].join(path.sep), |
| ) && |
| importPath.includes([path.sep, 'testing', path.sep].join(''))) { |
| /** |
| * Within test files we allow the direct import of test helpers. |
| * The entry point detection detects test_setup.ts as an |
| * entrypoint, but we don't treat it as such, it's just a file |
| * that Karma runs to setup the environment. |
| */ |
| return; |
| } |
| |
| if (importPath.endsWith('.css.js')) { |
| // We allow files to import CSS files within the same module. |
| return; |
| } |
| context.report({ |
| node, |
| messageId: 'incorrectSameNamespaceImportNamed', // Use messageId |
| data: { |
| importPathForErrorMessage, |
| }, |
| }); |
| } else if (path.dirname(importingFileName) === path.dirname(exportingFileName)) { |
| if (!importingFileName.endsWith('.test.ts') || !importingFileName.startsWith(FRONT_END_DIRECTORY)) { |
| return; |
| } |
| |
| const importingDirectoryName = path.basename( |
| path.dirname(importingFileName), |
| ); |
| if (importingDirectoryName === 'testing') { |
| // Special case of Foo.test.ts for a helper Foo.ts. |
| return; |
| } |
| |
| // Unit tests must import from the entry points even for same-namespace |
| // imports, as we otherwise break the module system (in Release builds). |
| if (!isModuleEntrypoint(exportingFileName)) { |
| reportIncorrectSameNamespaceTestImport( |
| context, |
| node, |
| importPathForErrorMessage, |
| importingDirectoryName, |
| ); |
| } |
| } |
| }, |
| }; |
| }, |
| }); |