diff --git a/.editorconfig b/.editorconfig index e2bfac523f..b7b8d09991 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,4 @@ insert_final_newline = true indent_style = space indent_size = 2 end_of_line = lf +quote_type = single diff --git a/.eslintignore b/.eslintignore index 9d22006820..3516f09b9c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,7 @@ tests/files/with-syntax-error tests/files/just-json-files/invalid.json tests/files/typescript-d-ts/ resolvers/webpack/test/files +examples # we want to ignore "tests/files" here, but unfortunately doing so would # interfere with unit test and fail it for some reason. # tests/files diff --git a/.eslintrc b/.eslintrc index 3c9c658f2f..80e1014c60 100644 --- a/.eslintrc +++ b/.eslintrc @@ -96,6 +96,7 @@ "no-multiple-empty-lines": [2, { "max": 1, "maxEOF": 1, "maxBOF": 0 }], "no-return-assign": [2, "always"], "no-trailing-spaces": 2, + "no-use-before-define": [2, { "functions": true, "classes": true, "variables": true }], "no-var": 2, "object-curly-spacing": [2, "always"], "object-shorthand": ["error", "always", { @@ -209,10 +210,10 @@ "exports": "always-multiline", "functions": "never" }], - "prefer-destructuring": "warn", + "prefer-destructuring": "off", "prefer-object-spread": "off", "prefer-rest-params": "off", - "prefer-spread": "warn", + "prefer-spread": "off", "prefer-template": "off", } }, @@ -225,6 +226,24 @@ "no-console": 1, }, }, + { + "files": [ + "utils/**", // TODO + ], + "rules": { + "no-use-before-define": "off", + }, + }, + { + "files": [ + "resolvers/webpack/index.js", + "resolvers/webpack/test/example.js", + "utils/parse.js", + ], + "rules": { + "no-console": "off", + }, + }, { "files": [ "resolvers/*/test/**/*", diff --git a/.github/workflows/native-wsl.yml b/.github/workflows/native-wsl.yml new file mode 100644 index 0000000000..5e8318899e --- /dev/null +++ b/.github/workflows/native-wsl.yml @@ -0,0 +1,155 @@ +name: Native and WSL + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.configuration == 'wsl' && 'wsl-bash {0}' || 'pwsh' }} + strategy: + fail-fast: false + matrix: + os: [windows-2019] + node-version: [18, 16, 14, 12, 10, 8, 6, 4] + configuration: [wsl, native] + + steps: + - uses: actions/checkout@v4 + - uses: Vampire/setup-wsl@v3 + if: matrix.configuration == 'wsl' + with: + distribution: Ubuntu-22.04 + - run: curl --version + - name: 'WSL: do all npm install steps' + if: matrix.configuration == 'wsl' + env: + ESLINT_VERSION: 7 + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + run: | + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm + nvm install --latest-npm ${{ matrix.node-version }} + + if [ ${{ matrix.node-version }} -ge 4 ] && [ ${{ matrix.node-version }} -lt 6 ]; then + npm install eslint@4 --no-save --ignore-scripts + npm install + npm install eslint-import-resolver-typescript@1.0.2 --no-save + npm uninstall @angular-eslint/template-parser @typescript-eslint/parser --no-save + fi + if [ ${{ matrix.node-version }} -ge 6 ] && [ ${{ matrix.node-version }} -lt 7 ]; then + npm install eslint@5 --no-save --ignore-scripts + npm install + npm uninstall @angular-eslint/template-parser --no-save + npm install eslint-import-resolver-typescript@1.0.2 @typescript-eslint/parser@3 --no-save + fi + if [ ${{ matrix.node-version }} -ge 7 ] && [ ${{ matrix.node-version }} -lt 8 ]; then + npm install eslint@6 --no-save --ignore-scripts + npm install + npm install eslint-import-resolver-typescript@1.0.2 typescript-eslint-parser@20 --no-save + npm uninstall @angular-eslint/template-parser --no-save + fi + if [ ${{ matrix.node-version }} -eq 8 ]; then + npm install eslint@6 --no-save --ignore-scripts + npm install + npm uninstall @angular-eslint/template-parser --no-save + npm install @typescript-eslint/parser@3 --no-save + fi + if [ ${{ matrix.node-version }} -gt 8 ] && [ ${{ matrix.node-version }} -lt 10 ]; then + npm install eslint@7 --no-save --ignore-scripts + npm install + npm install @typescript-eslint/parser@3 --no-save + fi + if [ ${{ matrix.node-version }} -ge 10 ] && [ ${{ matrix.node-version }} -lt 12 ]; then + npm install + npm install @typescript-eslint/parser@4 --no-save + fi + if [ ${{ matrix.node-version }} -ge 12 ]; then + npm install + fi + npm run copy-metafiles + npm run pretest + npm run tests-only + + - name: install dependencies for node <= 10 + if: matrix.node-version <= '10' && matrix.configuration == 'native' + run: | + npm install --legacy-peer-deps + npm install eslint@7 --no-save + + - name: Install dependencies for node > 10 + if: matrix.node-version > '10' && matrix.configuration == 'native' + run: npm install + + - name: install the latest version of nyc + if: matrix.configuration == 'native' + run: npm install nyc@latest --no-save + + - name: copy metafiles for node <= 8 + if: matrix.node-version <= 8 && matrix.configuration == 'native' + env: + ESLINT_VERSION: 6 + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + run: | + npm run copy-metafiles + bash ./tests/dep-time-travel.sh 2>&1 + - name: copy metafiles for Node > 8 + if: matrix.node-version > 8 && matrix.configuration == 'native' + env: + ESLINT_VERSION: 7 + TRAVIS_NODE_VERSION: ${{ matrix.node-version }} + run: | + npm run copy-metafiles + bash ./tests/dep-time-travel.sh 2>&1 + + - name: install ./resolver dependencies in Native + if: matrix.configuration == 'native' + shell: pwsh + run: | + npm config set package-lock false + $resolverDir = "./resolvers" + Get-ChildItem -Directory $resolverDir | + ForEach { + Write-output $(Resolve-Path $(Join-Path $resolverDir $_.Name)) + Push-Location $(Resolve-Path $(Join-Path $resolverDir $_.Name)) + npm install + npm ls nyc > $null; + if ($?) { + npm install nyc@latest --no-save + } + Pop-Location + } + + - name: run tests in Native + if: matrix.configuration == 'native' + shell: pwsh + run: | + npm run pretest + npm run tests-only + $resolverDir = "./resolvers"; + $resolvers = @(); + Get-ChildItem -Directory $resolverDir | + ForEach { + $resolvers += "$(Resolve-Path $(Join-Path $resolverDir $_.Name))"; + } + $env:RESOLVERS = [string]::Join(";", $resolvers); + foreach ($resolver in $resolvers) { + Set-Location -Path $resolver.Trim('"') + npm run tests-only + Set-Location -Path $PSScriptRoot + } + + - name: codecov + uses: codecov/codecov-action@v3.1.5 + + windows: + runs-on: ubuntu-latest + needs: [build] + steps: + - run: true diff --git a/.github/workflows/node-4+.yml b/.github/workflows/node-4+.yml index 2925adda8a..f2dad098ca 100644 --- a/.github/workflows/node-4+.yml +++ b/.github/workflows/node-4+.yml @@ -2,6 +2,10 @@ name: 'Tests: node.js' on: [pull_request, push] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -22,11 +26,14 @@ jobs: latest: needs: [matrix] name: 'majors' - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: + - ubuntu-latest + - macos-latest node-version: ${{ fromJson(needs.matrix.outputs.latest) }} eslint: - 8 @@ -38,16 +45,19 @@ jobs: - 2 include: - node-version: 'lts/*' + os: ubuntu-latest eslint: 7 ts-parser: 4 env: TS_PARSER: 4 - node-version: 'lts/*' + os: ubuntu-latest eslint: 7 ts-parser: 3 env: TS_PARSER: 3 - node-version: 'lts/*' + os: ubuntu-latest eslint: 7 ts-parser: 2 env: @@ -99,7 +109,7 @@ jobs: eslint: 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ljharb/actions/node/install@main continue-on-error: ${{ matrix.eslint == 4 && matrix.node-version == 4 }} name: 'nvm install ${{ matrix.node-version }} && npm install, with eslint ${{ matrix.eslint }}' @@ -113,7 +123,7 @@ jobs: skip-ls-check: true - run: npm run pretest - run: npm run tests-only - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v3.1.5 node: name: 'node 4+' diff --git a/.github/workflows/node-pretest.yml b/.github/workflows/node-pretest.yml index e4340018e4..f8db36de57 100644 --- a/.github/workflows/node-pretest.yml +++ b/.github/workflows/node-pretest.yml @@ -10,7 +10,7 @@ jobs: # runs-on: ubuntu-latest # steps: - # - uses: actions/checkout@v3 + # - uses: actions/checkout@v4 # - uses: ljharb/actions/node/install@main # name: 'nvm install lts/* && npm install' # with: @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ljharb/actions/node/install@main name: 'nvm install lts/* && npm install' with: diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index a6fb4e4cb5..f73f8e18ff 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -2,6 +2,10 @@ name: 'Tests: packages' on: [pull_request, push] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -38,7 +42,7 @@ jobs: # - utils steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ljharb/actions/node/install@main name: 'nvm install ${{ matrix.node-version }} && npm install' env: @@ -50,7 +54,7 @@ jobs: after_install: npm run copy-metafiles && ./tests/dep-time-travel.sh && cd ${{ matrix.package }} && npm install skip-ls-check: true - run: cd ${{ matrix.package }} && npm run tests-only - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v3.1.5 packages: name: 'packages: all tests' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 21a7070fb7..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,40 +0,0 @@ -language: node_js - -# osx backlog is often deep, so to be polite we can just hit these highlights -matrix: - include: - - os: osx - env: ESLINT_VERSION=5 - node_js: 14 - - os: osx - env: ESLINT_VERSION=5 - node_js: 12 - - os: osx - env: ESLINT_VERSION=5 - node_js: 10 - - os: osx - env: ESLINT_VERSION=4 - node_js: 8 - - os: osx - env: ESLINT_VERSION=3 - node_js: 6 - - os: osx - env: ESLINT_VERSION=2 - node_js: 4 - - fast_finish: true - -before_install: - - 'nvm install-latest-npm' - - 'NPM_CONFIG_LEGACY_PEER_DEPS=true npm install' - - 'npm run copy-metafiles' -install: - - 'NPM_CONFIG_LEGACY_PEER_DEPS=true npm install' - - 'if [ -n "${ESLINT_VERSION}" ]; then ./tests/dep-time-travel.sh; fi' - - 'npm run pretest' - -script: - - npm run tests-only - -after_success: - - bash <(curl -Os https://uploader.codecov.io/latest/linux/codecov) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81ad61a61..cf97fff94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## [Unreleased] +## [2.30.0] - 2024-09-02 + +### Added +- [`dynamic-import-chunkname`]: add `allowEmpty` option to allow empty leading comments ([#2942], thanks [@JiangWeixian]) +- [`dynamic-import-chunkname`]: Allow empty chunk name when webpackMode: 'eager' is set; add suggestions to remove name in eager mode ([#3004], thanks [@amsardesai]) +- [`no-unused-modules`]: Add `ignoreUnusedTypeExports` option ([#3011], thanks [@silverwind]) +- add support for Flat Config ([#3018], thanks [@michaelfaith]) + +### Fixed +- [`no-extraneous-dependencies`]: allow wrong path ([#3012], thanks [@chabb]) +- [`no-cycle`]: use scc algorithm to optimize ([#2998], thanks [@soryy708]) +- [`no-duplicates`]: Removing duplicates breaks in TypeScript ([#3033], thanks [@yesl-kim]) +- [`newline-after-import`]: fix considerComments option when require ([#2952], thanks [@developer-bandi]) +- [`order`]: do not compare first path segment for relative paths ([#2682]) ([#2885], thanks [@mihkeleidast]) + +### Changed +- [Docs] `no-extraneous-dependencies`: Make glob pattern description more explicit ([#2944], thanks [@mulztob]) +- [`no-unused-modules`]: add console message to help debug [#2866] +- [Refactor] `ExportMap`: make procedures static instead of monkeypatching exportmap ([#2982], thanks [@soryy708]) +- [Refactor] `ExportMap`: separate ExportMap instance from its builder logic ([#2985], thanks [@soryy708]) +- [Docs] `order`: Add a quick note on how unbound imports and --fix ([#2640], thanks [@minervabot]) +- [Tests] appveyor -> GHA (run tests on Windows in both pwsh and WSL + Ubuntu) ([#2987], thanks [@joeyguerra]) +- [actions] migrate OSX tests to GHA ([ljharb#37], thanks [@aks-]) +- [Refactor] `exportMapBuilder`: avoid hoisting ([#2989], thanks [@soryy708]) +- [Refactor] `ExportMap`: extract "builder" logic to separate files ([#2991], thanks [@soryy708]) +- [Docs] [`order`]: update the description of the `pathGroupsExcludedImportTypes` option ([#3036], thanks [@liby]) +- [readme] Clarify how to install the plugin ([#2993], thanks [@jwbth]) + ## [2.29.1] - 2023-12-14 ### Fixed @@ -1101,8 +1129,26 @@ for info on changes for earlier releases. [`memo-parser`]: ./memo-parser/README.md +[#3036]: https://github.com/import-js/eslint-plugin-import/pull/3036 +[#3033]: https://github.com/import-js/eslint-plugin-import/pull/3033 +[#3018]: https://github.com/import-js/eslint-plugin-import/pull/3018 +[#3012]: https://github.com/import-js/eslint-plugin-import/pull/3012 +[#3011]: https://github.com/import-js/eslint-plugin-import/pull/3011 +[#3004]: https://github.com/import-js/eslint-plugin-import/pull/3004 +[#2998]: https://github.com/import-js/eslint-plugin-import/pull/2998 +[#2993]: https://github.com/import-js/eslint-plugin-import/pull/2993 +[#2991]: https://github.com/import-js/eslint-plugin-import/pull/2991 +[#2989]: https://github.com/import-js/eslint-plugin-import/pull/2989 +[#2987]: https://github.com/import-js/eslint-plugin-import/pull/2987 +[#2985]: https://github.com/import-js/eslint-plugin-import/pull/2985 +[#2982]: https://github.com/import-js/eslint-plugin-import/pull/2982 +[#2952]: https://github.com/import-js/eslint-plugin-import/pull/2952 +[#2944]: https://github.com/import-js/eslint-plugin-import/pull/2944 +[#2942]: https://github.com/import-js/eslint-plugin-import/pull/2942 [#2919]: https://github.com/import-js/eslint-plugin-import/pull/2919 +[#2885]: https://github.com/import-js/eslint-plugin-import/pull/2885 [#2884]: https://github.com/import-js/eslint-plugin-import/pull/2884 +[#2866]: https://github.com/import-js/eslint-plugin-import/pull/2866 [#2854]: https://github.com/import-js/eslint-plugin-import/pull/2854 [#2851]: https://github.com/import-js/eslint-plugin-import/pull/2851 [#2850]: https://github.com/import-js/eslint-plugin-import/pull/2850 @@ -1116,6 +1162,7 @@ for info on changes for earlier releases. [#2735]: https://github.com/import-js/eslint-plugin-import/pull/2735 [#2699]: https://github.com/import-js/eslint-plugin-import/pull/2699 [#2664]: https://github.com/import-js/eslint-plugin-import/pull/2664 +[#2640]: https://github.com/import-js/eslint-plugin-import/pull/2640 [#2613]: https://github.com/import-js/eslint-plugin-import/pull/2613 [#2608]: https://github.com/import-js/eslint-plugin-import/pull/2608 [#2605]: https://github.com/import-js/eslint-plugin-import/pull/2605 @@ -1440,9 +1487,12 @@ for info on changes for earlier releases. [#164]: https://github.com/import-js/eslint-plugin-import/pull/164 [#157]: https://github.com/import-js/eslint-plugin-import/pull/157 +[ljharb#37]: https://github.com/ljharb/eslint-plugin-import/pull/37 + [#2930]: https://github.com/import-js/eslint-plugin-import/issues/2930 [#2687]: https://github.com/import-js/eslint-plugin-import/issues/2687 [#2684]: https://github.com/import-js/eslint-plugin-import/issues/2684 +[#2682]: https://github.com/import-js/eslint-plugin-import/issues/2682 [#2674]: https://github.com/import-js/eslint-plugin-import/issues/2674 [#2668]: https://github.com/import-js/eslint-plugin-import/issues/2668 [#2666]: https://github.com/import-js/eslint-plugin-import/issues/2666 @@ -1567,7 +1617,8 @@ for info on changes for earlier releases. [#119]: https://github.com/import-js/eslint-plugin-import/issues/119 [#89]: https://github.com/import-js/eslint-plugin-import/issues/89 -[Unreleased]: https://github.com/import-js/eslint-plugin-import/compare/v2.29.1...HEAD +[Unreleased]: https://github.com/import-js/eslint-plugin-import/compare/v2.30.0...HEAD +[2.30.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.29.1...v2.30.0 [2.29.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.29.0...v2.29.1 [2.29.0]: https://github.com/import-js/eslint-plugin-import/compare/v2.28.1...v2.29.0 [2.28.1]: https://github.com/import-js/eslint-plugin-import/compare/v2.28.0...v2.28.1 @@ -1672,9 +1723,11 @@ for info on changes for earlier releases. [@adjerbetian]: https://github.com/adjerbetian [@AdriAt360]: https://github.com/AdriAt360 [@ai]: https://github.com/ai +[@aks-]: https://github.com/aks- [@aladdin-add]: https://github.com/aladdin-add [@alex-page]: https://github.com/alex-page [@alexgorbatchev]: https://github.com/alexgorbatchev +[@amsardesai]: https://github.com/amsardesai [@andreubotella]: https://github.com/andreubotella [@AndrewLeedham]: https://github.com/AndrewLeedham [@andyogo]: https://github.com/andyogo @@ -1699,11 +1752,13 @@ for info on changes for earlier releases. [@bicstone]: https://github.com/bicstone [@Blasz]: https://github.com/Blasz [@bmish]: https://github.com/bmish +[@developer-bandi]: https://github.com/developer-bandi [@borisyankov]: https://github.com/borisyankov [@bradennapier]: https://github.com/bradennapier [@bradzacher]: https://github.com/bradzacher [@brendo]: https://github.com/brendo [@brettz9]: https://github.com/brettz9 +[@chabb]: https://github.com/chabb [@Chamion]: https://github.com/Chamion [@charlessuh]: https://github.com/charlessuh [@charpeni]: https://github.com/charpeni @@ -1770,10 +1825,12 @@ for info on changes for earlier releases. [@jeffshaver]: https://github.com/jeffshaver [@jf248]: https://github.com/jf248 [@jfmengels]: https://github.com/jfmengels +[@JiangWeixian]: https://github.com/JiangWeixian [@jimbolla]: https://github.com/jimbolla [@jkimbo]: https://github.com/jkimbo [@joaovieira]: https://github.com/joaovieira [@joe-matsec]: https://github.com/joe-matsec +[@joeyguerra]: https://github.com/joeyguerra [@johndevedu]: https://github.com/johndevedu [@johnthagen]: https://github.com/johnthagen [@jonboiser]: https://github.com/jonboiser @@ -1783,6 +1840,7 @@ for info on changes for earlier releases. [@jseminck]: https://github.com/jseminck [@julien1619]: https://github.com/julien1619 [@justinanastos]: https://github.com/justinanastos +[@jwbth]: https://github.com/jwbth [@k15a]: https://github.com/k15a [@kentcdodds]: https://github.com/kentcdodds [@kevin940726]: https://github.com/kevin940726 @@ -1830,11 +1888,15 @@ for info on changes for earlier releases. [@meowtec]: https://github.com/meowtec [@mgwalker]: https://github.com/mgwalker [@mhmadhamster]: https://github.com/MhMadHamster +[@michaelfaith]: https://github.com/michaelfaith +[@mihkeleidast]: https://github.com/mihkeleidast [@MikeyBeLike]: https://github.com/MikeyBeLike +[@minervabot]: https://github.com/minervabot [@mpint]: https://github.com/mpint [@mplewis]: https://github.com/mplewis [@mrmckeb]: https://github.com/mrmckeb [@msvab]: https://github.com/msvab +[@mulztob]: https://github.com/mulztob [@mx-bernhard]: https://github.com/mx-bernhard [@Nfinished]: https://github.com/Nfinished [@nickofthyme]: https://github.com/nickofthyme @@ -1843,9 +1905,9 @@ for info on changes for earlier releases. [@ntdb]: https://github.com/ntdb [@nwalters512]: https://github.com/nwalters512 [@ombene]: https://github.com/ombene -[@Pandemic1617]: https://github.com/Pandemic1617 [@ota-meshi]: https://github.com/ota-meshi [@OutdatedVersion]: https://github.com/OutdatedVersion +[@Pandemic1617]: https://github.com/Pandemic1617 [@panrafal]: https://github.com/panrafal [@paztis]: https://github.com/paztis [@pcorpet]: https://github.com/pcorpet @@ -1877,6 +1939,7 @@ for info on changes for earlier releases. [@sergei-startsev]: https://github.com/sergei-startsev [@sharmilajesupaul]: https://github.com/sharmilajesupaul [@sheepsteak]: https://github.com/sheepsteak +[@silverwind]: https://github.com/silverwind [@silviogutierrez]: https://github.com/silviogutierrez [@SimenB]: https://github.com/SimenB [@simmo]: https://github.com/simmo @@ -1919,6 +1982,7 @@ for info on changes for earlier releases. [@wtgtybhertgeghgtwtg]: https://github.com/wtgtybhertgeghgtwtg [@xM8WVqaG]: https://github.com/xM8WVqaG [@xpl]: https://github.com/xpl +[@yesl-kim]: https://github.com/yesl-kim [@yndajas]: https://github.com/yndajas [@yordis]: https://github.com/yordis [@Zamiell]: https://github.com/Zamiell diff --git a/README.md b/README.md index 1baa0069b3..8cc723423f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a ⌨️ Set in the `typescript` configuration.\ 🚸 Set in the `warnings` configuration.\ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\ -💡 Manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).\ +💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\ ❌ Deprecated. ### Helpful warnings @@ -73,7 +73,7 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a | Name                            | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | ❌ | | :------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :- | :---- | :- | :- | :- | :- | | [consistent-type-specifier-style](docs/rules/consistent-type-specifier-style.md) | Enforce or ban the use of inline type-only markers for named imports. | | | | 🔧 | | | -| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | | | +| [dynamic-import-chunkname](docs/rules/dynamic-import-chunkname.md) | Enforce a leading comment with the webpackChunkName for dynamic imports. | | | | | 💡 | | | [exports-last](docs/rules/exports-last.md) | Ensure all exports appear after other statements. | | | | | | | | [extensions](docs/rules/extensions.md) | Ensure consistent use of file extension within the import path. | | | | | | | | [first](docs/rules/first.md) | Ensure all imports appear before other statements. | | | | 🔧 | | | @@ -106,29 +106,60 @@ The maintainers of `eslint-plugin-import` and thousands of other packages are wo npm install eslint-plugin-import --save-dev ``` -All rules are off by default. However, you may configure them manually -in your `.eslintrc.(yml|json|js)`, or extend one of the canned configs: +### Config - Legacy (`.eslintrc`) -```yaml ---- -extends: - - eslint:recommended - - plugin:import/recommended - # alternatively, 'recommended' is the combination of these two rule sets: - - plugin:import/errors - - plugin:import/warnings - -# or configure manually: -plugins: - - import - -rules: - import/no-unresolved: [2, {commonjs: true, amd: true}] - import/named: 2 - import/namespace: 2 - import/default: 2 - import/export: 2 - # etc... +All rules are off by default. However, you may extend one of the preset configs, or configure them manually in your `.eslintrc.(yml|json|js)`. + + - Extending a preset config: + +```jsonc +{ + "extends": [ + "eslint:recommended", + "plugin:import/recommended", + ], +} +``` + + - Configuring manually: + +```jsonc +{ + "rules": { + "import/no-unresolved": ["error", { "commonjs": true, "amd": true }] + "import/named": "error", + "import/namespace": "error", + "import/default": "error", + "import/export": "error", + // etc... + }, +}, +``` + +### Config - Flat (`eslint.config.js`) + +All rules are off by default. However, you may configure them manually in your `eslint.config.(js|cjs|mjs)`, or extend one of the preset configs: + +```js +import importPlugin from 'eslint-plugin-import'; +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + importPlugin.flatConfigs.recommended, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + }, + }, +]; ``` ## TypeScript @@ -137,18 +168,23 @@ You may use the following snippet or assemble your own config using the granular Make sure you have installed [`@typescript-eslint/parser`] and [`eslint-import-resolver-typescript`] which are used in the following configuration. -```yaml -extends: - - eslint:recommended - - plugin:import/recommended -# the following lines do the trick - - plugin:import/typescript -settings: - import/resolver: - # You will also need to install and configure the TypeScript resolver - # See also https://github.com/import-js/eslint-import-resolver-typescript#configuration - typescript: true - node: true +```jsonc +{ + "extends": [ + "eslint:recommended", + "plugin:import/recommended", +// the following lines do the trick + "plugin:import/typescript", + ], + "settings": { + "import/resolver": { + // You will also need to install and configure the TypeScript resolver + // See also https://github.com/import-js/eslint-import-resolver-typescript#configuration + "typescript": true, + "node": true, + }, + }, +} ``` [`@typescript-eslint/parser`]: https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser @@ -177,6 +213,16 @@ You can reference resolvers in several ways (in order of precedence): - as a conventional `eslint-import-resolver` name, like `eslint-import-resolver-foo`: + ```jsonc +// .eslintrc +{ + "settings": { + // uses 'eslint-import-resolver-foo': + "import/resolver": "foo", + }, +} +``` + ```yaml # .eslintrc.yml settings: @@ -197,6 +243,15 @@ module.exports = { - with a full npm module name, like `my-awesome-npm-module`: +```jsonc +// .eslintrc +{ + "settings": { + "import/resolver": "my-awesome-npm-module", + }, +} +``` + ```yaml # .eslintrc.yml settings: @@ -292,11 +347,15 @@ In practice, this means rules other than [`no-unresolved`](./docs/rules/no-unres `no-unresolved` has its own [`ignore`](./docs/rules/no-unresolved.md#ignore) setting. -```yaml -settings: - import/ignore: - - \.coffee$ # fraught with parse errors - - \.(scss|less|css)$ # can't parse unprocessed CSS modules, either +```jsonc +{ + "settings": { + "import/ignore": [ + "\.coffee$", // fraught with parse errors + "\.(scss|less|css)$", // can't parse unprocessed CSS modules, either + ], + }, +} ``` ### `import/core-modules` @@ -315,10 +374,13 @@ import 'electron' // without extra config, will be flagged as unresolved! that would otherwise be unresolved. To avoid this, you may provide `electron` as a core module: -```yaml -# .eslintrc.yml -settings: - import/core-modules: [ electron ] +```jsonc +// .eslintrc +{ + "settings": { + "import/core-modules": ["electron"], + }, +} ``` In Electron's specific case, there is a shared config named `electron` @@ -351,11 +413,15 @@ dependency parser will require and use the map key as the parser instead of the configured ESLint parser. This is useful if you're inter-op-ing with TypeScript directly using webpack, for example: -```yaml -# .eslintrc.yml -settings: - import/parsers: - "@typescript-eslint/parser": [ .ts, .tsx ] +```jsonc +// .eslintrc +{ + "settings": { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + }, +} ``` In this case, [`@typescript-eslint/parser`](https://www.npmjs.com/package/@typescript-eslint/parser) @@ -385,20 +451,28 @@ For long-lasting processes, like [`eslint_d`] or [`eslint-loader`], however, it' If you never use [`eslint_d`] or [`eslint-loader`], you may set the cache lifetime to `Infinity` and everything should be fine: -```yaml -# .eslintrc.yml -settings: - import/cache: - lifetime: ∞ # or Infinity +```jsonc +// .eslintrc +{ + "settings": { + "import/cache": { + "lifetime": "∞", // or Infinity, in a JS config + }, + }, +} ``` Otherwise, set some integer, and cache entries will be evicted after that many seconds have elapsed: -```yaml -# .eslintrc.yml -settings: - import/cache: - lifetime: 5 # 30 is the default +```jsonc +// .eslintrc +{ + "settings": { + "import/cache": { + "lifetime": 5, // 30 is the default + }, + }, +} ``` [`eslint_d`]: https://www.npmjs.com/package/eslint_d @@ -412,10 +486,13 @@ By default, any package referenced from [`import/external-module-folders`](#impo For example, if your packages in a monorepo are all in `@scope`, you can configure `import/internal-regex` like this -```yaml -# .eslintrc.yml -settings: - import/internal-regex: ^@scope/ +```jsonc +// .eslintrc +{ + "settings": { + "import/internal-regex": "^@scope/", + }, +} ``` ## SublimeLinter-eslint diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index e50ab87d2a..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,165 +0,0 @@ -configuration: - - Native - - WSL - -# Test against this version of Node.js -environment: - matrix: - - nodejs_version: "16" - - nodejs_version: "14" - - nodejs_version: "12" - - nodejs_version: "10" - - nodejs_version: "8" - # - nodejs_version: "6" - # - nodejs_version: "4" - -image: Visual Studio 2019 -matrix: - fast_finish: false - exclude: - - configuration: WSL - nodejs_version: "8" - - configuration: WSL - nodejs_version: "6" - - configuration: WSL - nodejs_version: "4" - - allow_failures: - - nodejs_version: "4" # for eslint 5 - - configuration: WSL - -platform: - - x86 - - x64 - -# Initialization scripts. (runs before repo cloning) -init: - # Declare version-numbers of packages to install - - ps: >- - if ($env:nodejs_version -eq "4") { - $env:NPM_VERSION="3" - } - if ($env:nodejs_version -in @("8")) { - $env:NPM_VERSION="6" - } - if ($env:nodejs_version -in @("10", "12", "14", "16")) { - $env:NPM_VERSION="6" # TODO: use npm 7 - $env:NPM_CONFIG_LEGACY_PEER_DEPS="true" - } - - ps: >- - $env:ESLINT_VERSION="7"; - if ([int]$env:nodejs_version -le 8) { - $env:ESLINT_VERSION="6" - } - if ([int]$env:nodejs_version -le 7) { - $env:ESLINT_VERSION="5" - } - if ([int]$env:nodejs_version -le 6) { - $env:ESLINT_VERSION="4" - } - - ps: $env:WINDOWS_NYC_VERSION = "15.0.1" - - ps: $env:TRAVIS_NODE_VERSION = $env:nodejs_version - - # Add `ci`-command to `PATH` for running commands either using cmd or wsl depending on the configuration - - ps: $env:PATH += ";$(Join-Path $(pwd) "scripts")" - -# Install scripts. (runs after repo cloning) -before_build: - # Install propert `npm`-version - - IF DEFINED NPM_VERSION ci sudo npm install -g npm@%NPM_VERSION% - - # Install dependencies - - ci npm install - - ci npm run copy-metafiles - - bash ./tests/dep-time-travel.sh 2>&1 - - # fix symlinks - - git config core.symlinks true - - git reset --hard - - ci git reset --hard - - # Install dependencies of resolvers - - ps: >- - $resolverDir = "./resolvers"; - $resolvers = @(); - Get-ChildItem -Directory $resolverDir | - ForEach { - $resolvers += "$(Resolve-Path $(Join-Path $resolverDir $_))"; - } - $env:RESOLVERS = [string]::Join(";", $resolvers); - - FOR %%G in ("%RESOLVERS:;=";"%") do ( pushd %%~G & ci npm install & popd ) - - # Install proper `eslint`-version - - IF DEFINED ESLINT_VERSION ci npm install --no-save eslint@%ESLINT_VERSION% - -# Build scripts (project isn't actually built) -build_script: - - ps: "# This Project isn't actually built" - -# Test scripts -test_script: - # Output useful info for debugging. - - ci node --version - - ci npm --version - - # Run core tests - - ci npm run pretest - - ci npm run tests-only - - # Run resolver tests - - ps: >- - $resolverDir = "./resolvers"; - $resolvers = @(); - Get-ChildItem -Directory $resolverDir | - ForEach { - $resolvers += "$(Resolve-Path $(Join-Path $resolverDir $_))"; - } - $env:RESOLVERS = [string]::Join(";", $resolvers); - - FOR %%G in ("%RESOLVERS:;=";"%") do ( pushd %%~G & ci npm test & popd ) - -# Configuration-specific steps -for: - - matrix: - except: - - configuration: WSL - install: - # Get the latest stable version of Node.js or io.js - - ps: Install-Product node $env:nodejs_version - before_test: - # Upgrade nyc - - ci npm i --no-save nyc@%WINDOWS_NYC_VERSION% - - ps: >- - $resolverDir = "./resolvers"; - $resolvers = @(); - Get-ChildItem -Directory $resolverDir | - ForEach { - Push-Location $(Resolve-Path $(Join-Path $resolverDir $_)); - ci npm ls nyc > $null; - if ($?) { - $resolvers += "$(pwd)"; - } - Pop-Location; - } - $env:RESOLVERS = [string]::Join(";", $resolvers); - - IF DEFINED RESOLVERS FOR %%G in ("%RESOLVERS:;=";"%") do ( pushd %%~G & ci npm install --no-save nyc@%WINDOWS_NYC_VERSION% & popd ) - # TODO: enable codecov for native windows builds - #on_success: - #- ci $ProgressPreference = 'SilentlyContinue' - #- ci Invoke-WebRequest -Uri https://uploader.codecov.io/latest/windows/codecov.exe - #- ci -Outfile codecov.exe - #- ci .\codecov.exe - - matrix: - only: - - configuration: WSL - # Install scripts. (runs after repo cloning) - install: - # Get a specific version of Node.js - - ps: $env:WSLENV += ":nodejs_version" - - ps: wsl curl -sL 'https://deb.nodesource.com/setup_${nodejs_version}.x' `| sudo APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 -E bash - - - wsl sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs - on_success: - - ci curl -Os https://uploader.codecov.io/latest/linux/codecov - - ci chmod +x codecov - - ci ./codecov - -build: on diff --git a/config/flat/errors.js b/config/flat/errors.js new file mode 100644 index 0000000000..98c19f824d --- /dev/null +++ b/config/flat/errors.js @@ -0,0 +1,14 @@ +/** + * unopinionated config. just the things that are necessarily runtime errors + * waiting to happen. + * @type {Object} + */ +module.exports = { + rules: { + 'import/no-unresolved': 2, + 'import/named': 2, + 'import/namespace': 2, + 'import/default': 2, + 'import/export': 2, + }, +}; diff --git a/config/flat/react.js b/config/flat/react.js new file mode 100644 index 0000000000..0867471422 --- /dev/null +++ b/config/flat/react.js @@ -0,0 +1,19 @@ +/** + * Adds `.jsx` as an extension, and enables JSX parsing. + * + * Even if _you_ aren't using JSX (or .jsx) directly, if your dependencies + * define jsnext:main and have JSX internally, you may run into problems + * if you don't enable these settings at the top level. + */ +module.exports = { + settings: { + 'import/extensions': ['.js', '.jsx', '.mjs', '.cjs'], + }, + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}; diff --git a/config/flat/recommended.js b/config/flat/recommended.js new file mode 100644 index 0000000000..11bc1f52a4 --- /dev/null +++ b/config/flat/recommended.js @@ -0,0 +1,26 @@ +/** + * The basics. + * @type {Object} + */ +module.exports = { + rules: { + // analysis/correctness + 'import/no-unresolved': 'error', + 'import/named': 'error', + 'import/namespace': 'error', + 'import/default': 'error', + 'import/export': 'error', + + // red flags (thus, warnings) + 'import/no-named-as-default': 'warn', + 'import/no-named-as-default-member': 'warn', + 'import/no-duplicates': 'warn', + }, + + // need all these for parsing dependencies (even if _your_ code doesn't need + // all of them) + languageOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, +}; diff --git a/config/flat/warnings.js b/config/flat/warnings.js new file mode 100644 index 0000000000..e788ff9cde --- /dev/null +++ b/config/flat/warnings.js @@ -0,0 +1,11 @@ +/** + * more opinionated config. + * @type {Object} + */ +module.exports = { + rules: { + 'import/no-named-as-default': 1, + 'import/no-named-as-default-member': 1, + 'import/no-duplicates': 1, + }, +}; diff --git a/config/react.js b/config/react.js index 68555512d7..1ae8e1a51a 100644 --- a/config/react.js +++ b/config/react.js @@ -6,7 +6,6 @@ * if you don't enable these settings at the top level. */ module.exports = { - settings: { 'import/extensions': ['.js', '.jsx'], }, @@ -14,5 +13,4 @@ module.exports = { parserOptions: { ecmaFeatures: { jsx: true }, }, - }; diff --git a/config/typescript.js b/config/typescript.js index ff7d0795c8..d5eb57a465 100644 --- a/config/typescript.js +++ b/config/typescript.js @@ -9,7 +9,7 @@ // `.ts`/`.tsx`/`.js`/`.jsx` implementation. const typeScriptExtensions = ['.ts', '.cts', '.mts', '.tsx']; -const allExtensions = [...typeScriptExtensions, '.js', '.jsx']; +const allExtensions = [...typeScriptExtensions, '.js', '.jsx', '.mjs', '.cjs']; module.exports = { settings: { diff --git a/docs/rules/dynamic-import-chunkname.md b/docs/rules/dynamic-import-chunkname.md index 35ae9df516..de554148ee 100644 --- a/docs/rules/dynamic-import-chunkname.md +++ b/docs/rules/dynamic-import-chunkname.md @@ -1,5 +1,7 @@ # import/dynamic-import-chunkname +💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + This rule reports any dynamic imports without a webpackChunkName specified in a leading block comment in the proper format. @@ -15,7 +17,8 @@ You can also configure the regex format you'd like to accept for the webpackChun { "dynamic-import-chunkname": [2, { importFunctions: ["dynamicImport"], - webpackChunknameFormat: "[a-zA-Z0-57-9-/_]+" + webpackChunknameFormat: "[a-zA-Z0-57-9-/_]+", + allowEmpty: false }] } ``` @@ -55,6 +58,13 @@ import( // webpackChunkName: "someModule" 'someModule', ); + +// chunk names are disallowed when eager mode is set +import( + /* webpackMode: "eager" */ + /* webpackChunkName: "someModule" */ + 'someModule', +) ``` ### valid @@ -87,6 +97,38 @@ The following patterns are valid: ); ``` +### `allowEmpty: true` + +If you want to allow dynamic imports without a webpackChunkName, you can set `allowEmpty: true` in the rule config. This will allow dynamic imports without a leading comment, or with a leading comment that does not contain a webpackChunkName. + +Given `{ "allowEmpty": true }`: + + +### valid + +The following patterns are valid: + +```javascript +import('someModule'); + +import( + /* webpackChunkName: "someModule" */ + 'someModule', +); +``` + +### invalid + +The following patterns are invalid: + +```javascript +// incorrectly formatted comment +import( + /*webpackChunkName:"someModule"*/ + 'someModule', +); +``` + ## When Not To Use It If you don't care that webpack will autogenerate chunk names and may blow up browser caches and bundle size reports. diff --git a/docs/rules/no-empty-named-blocks.md b/docs/rules/no-empty-named-blocks.md index 85821d8afe..ad83c535f8 100644 --- a/docs/rules/no-empty-named-blocks.md +++ b/docs/rules/no-empty-named-blocks.md @@ -1,6 +1,6 @@ # import/no-empty-named-blocks -🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). +🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). diff --git a/docs/rules/no-extraneous-dependencies.md b/docs/rules/no-extraneous-dependencies.md index 547e5c2e57..848d5bb0da 100644 --- a/docs/rules/no-extraneous-dependencies.md +++ b/docs/rules/no-extraneous-dependencies.md @@ -32,7 +32,7 @@ You can also use an array of globs instead of literal booleans: "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js"]}] ``` -When using an array of globs, the setting will be set to `true` (no errors reported) if the name of the file being linted matches a single glob in the array, and `false` otherwise. +When using an array of globs, the setting will be set to `true` (no errors reported) if the name of the file being linted (i.e. not the imported file/module) matches a single glob in the array, and `false` otherwise. There are 2 boolean options to opt into checking extra imports that are normally ignored: `includeInternal`, which enables the checking of internal modules, and `includeTypes`, which enables checking of type imports in TypeScript. diff --git a/docs/rules/no-unused-modules.md b/docs/rules/no-unused-modules.md index 53c2479272..359c341ea0 100644 --- a/docs/rules/no-unused-modules.md +++ b/docs/rules/no-unused-modules.md @@ -29,8 +29,9 @@ This rule takes the following option: - **`missingExports`**: if `true`, files without any exports are reported (defaults to `false`) - **`unusedExports`**: if `true`, exports without any static usage within other modules are reported (defaults to `false`) - - `src`: an array with files/paths to be analyzed. It only applies to unused exports. Defaults to `process.cwd()`, if not provided - - `ignoreExports`: an array with files/paths for which unused exports will not be reported (e.g module entry points in a published package) + - **`ignoreUnusedTypeExports`**: if `true`, TypeScript type exports without any static usage within other modules are reported (defaults to `false` and has no effect unless `unusedExports` is `true`) + - **`src`**: an array with files/paths to be analyzed. It only applies to unused exports. Defaults to `process.cwd()`, if not provided + - **`ignoreExports`**: an array with files/paths for which unused exports will not be reported (e.g module entry points in a published package) ### Example for missing exports @@ -116,6 +117,16 @@ export function doAnything() { export default 5 // will not be reported ``` +### Unused exports with `ignoreUnusedTypeExports` set to `true` + +The following will not be reported: + +```ts +export type Foo = {}; // will not be reported +export interface Foo = {}; // will not be reported +export enum Foo {}; // will not be reported +``` + #### Important Note Exports from files listed as a main file (`main`, `browser`, or `bin` fields in `package.json`) will be ignored by default. This only applies if the `package.json` is not set to `private: true` diff --git a/docs/rules/order.md b/docs/rules/order.md index 2335699e6c..67849bb7ed 100644 --- a/docs/rules/order.md +++ b/docs/rules/order.md @@ -77,6 +77,25 @@ import foo from './foo'; var path = require('path'); ``` +## Limitations of `--fix` + +Unbound imports are assumed to have side effects, and will never be moved/reordered. This can cause other imports to get "stuck" around them, and the fix to fail. + +```javascript +import b from 'b' +import 'format.css'; // This will prevent --fix from working. +import a from 'a' +``` + +As a workaround, move unbound imports to be entirely above or below bound ones. + +```javascript +import 'format1.css'; // OK +import b from 'b' +import a from 'a' +import 'format2.css'; // OK +``` + ## Options This rule supports the following options: @@ -174,7 +193,7 @@ Example: ### `pathGroupsExcludedImportTypes: [array]` This defines import types that are not handled by configured pathGroups. -This is mostly needed when you want to handle path groups that look like external imports. +If you have added path groups with patterns that look like `"builtin"` or `"external"` imports, you have to remove this group (`"builtin"` and/or `"external"`) from the default exclusion list (e.g., `["builtin", "external", "object"]`, etc) to sort these path groups correctly. Example: @@ -193,29 +212,7 @@ Example: } ``` -You can also use `patterns`(e.g., `react`, `react-router-dom`, etc). - -Example: - -```json -{ - "import/order": [ - "error", - { - "pathGroups": [ - { - "pattern": "react", - "group": "builtin", - "position": "before" - } - ], - "pathGroupsExcludedImportTypes": ["react"] - } - ] -} -``` - -The default value is `["builtin", "external", "object"]`. +[Import Type](https://github.com/import-js/eslint-plugin-import/blob/HEAD/src/core/importType.js#L90) is resolved as a fixed string in predefined set, it can't be a `patterns`(e.g., `react`, `react-router-dom`, etc). See [#2156] for details. ### `newlines-between: [ignore|always|always-and-inside-groups|never]` diff --git a/examples/flat/eslint.config.mjs b/examples/flat/eslint.config.mjs new file mode 100644 index 0000000000..370514a65b --- /dev/null +++ b/examples/flat/eslint.config.mjs @@ -0,0 +1,25 @@ +import importPlugin from 'eslint-plugin-import'; +import js from '@eslint/js'; +import tsParser from '@typescript-eslint/parser'; + +export default [ + js.configs.recommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.react, + importPlugin.flatConfigs.typescript, + { + files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'], + languageOptions: { + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module', + }, + ignores: ['eslint.config.mjs', '**/exports-unused.ts'], + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + 'import/no-unused-modules': ['warn', { unusedExports: true }], + }, + }, +]; diff --git a/examples/flat/package.json b/examples/flat/package.json new file mode 100644 index 0000000000..0894d29f28 --- /dev/null +++ b/examples/flat/package.json @@ -0,0 +1,17 @@ +{ + "name": "flat", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=true eslint src --report-unused-disable-directives" + }, + "devDependencies": { + "@eslint/js": "^9.5.0", + "@types/node": "^20.14.5", + "@typescript-eslint/parser": "^7.13.1", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "eslint-plugin-import": "file:../..", + "typescript": "^5.4.5" + } +} diff --git a/examples/flat/src/exports-unused.ts b/examples/flat/src/exports-unused.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/flat/src/exports-unused.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/flat/src/exports.ts b/examples/flat/src/exports.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/flat/src/exports.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/flat/src/imports.ts b/examples/flat/src/imports.ts new file mode 100644 index 0000000000..643219ae42 --- /dev/null +++ b/examples/flat/src/imports.ts @@ -0,0 +1,7 @@ +//import c from './exports'; +import { a, b } from './exports'; +import type { ScalarType, ObjType } from './exports'; + +import path from 'path'; +import fs from 'node:fs'; +import console from 'console'; diff --git a/examples/flat/src/jsx.tsx b/examples/flat/src/jsx.tsx new file mode 100644 index 0000000000..970d53cb84 --- /dev/null +++ b/examples/flat/src/jsx.tsx @@ -0,0 +1,3 @@ +const Components = () => { + return <>; +}; diff --git a/examples/flat/tsconfig.json b/examples/flat/tsconfig.json new file mode 100644 index 0000000000..e100bfc980 --- /dev/null +++ b/examples/flat/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "rootDir": "./", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/examples/legacy/.eslintrc.cjs b/examples/legacy/.eslintrc.cjs new file mode 100644 index 0000000000..e3cec097f0 --- /dev/null +++ b/examples/legacy/.eslintrc.cjs @@ -0,0 +1,24 @@ +module.exports = { + root: true, + env: { es2022: true }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/react', + 'plugin:import/typescript', + ], + settings: {}, + ignorePatterns: ['.eslintrc.cjs', '**/exports-unused.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['import'], + rules: { + 'no-unused-vars': 'off', + 'import/no-dynamic-require': 'warn', + 'import/no-nodejs-modules': 'warn', + 'import/no-unused-modules': ['warn', { unusedExports: true }], + }, +}; diff --git a/examples/legacy/package.json b/examples/legacy/package.json new file mode 100644 index 0000000000..e3ca094887 --- /dev/null +++ b/examples/legacy/package.json @@ -0,0 +1,16 @@ +{ + "name": "legacy", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint src --ext js,jsx,ts,tsx --report-unused-disable-directives" + }, + "devDependencies": { + "@types/node": "^20.14.5", + "@typescript-eslint/parser": "^7.13.1", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "eslint-plugin-import": "file:../..", + "typescript": "^5.4.5" + } +} diff --git a/examples/legacy/src/exports-unused.ts b/examples/legacy/src/exports-unused.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/legacy/src/exports-unused.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/legacy/src/exports.ts b/examples/legacy/src/exports.ts new file mode 100644 index 0000000000..af8061ec2b --- /dev/null +++ b/examples/legacy/src/exports.ts @@ -0,0 +1,12 @@ +export type ScalarType = string | number; +export type ObjType = { + a: ScalarType; + b: ScalarType; +}; + +export const a = 13; +export const b = 18; + +const defaultExport: ObjType = { a, b }; + +export default defaultExport; diff --git a/examples/legacy/src/imports.ts b/examples/legacy/src/imports.ts new file mode 100644 index 0000000000..643219ae42 --- /dev/null +++ b/examples/legacy/src/imports.ts @@ -0,0 +1,7 @@ +//import c from './exports'; +import { a, b } from './exports'; +import type { ScalarType, ObjType } from './exports'; + +import path from 'path'; +import fs from 'node:fs'; +import console from 'console'; diff --git a/examples/legacy/src/jsx.tsx b/examples/legacy/src/jsx.tsx new file mode 100644 index 0000000000..970d53cb84 --- /dev/null +++ b/examples/legacy/src/jsx.tsx @@ -0,0 +1,3 @@ +const Components = () => { + return <>; +}; diff --git a/examples/legacy/tsconfig.json b/examples/legacy/tsconfig.json new file mode 100644 index 0000000000..e100bfc980 --- /dev/null +++ b/examples/legacy/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "rootDir": "./", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/memo-parser/package.json b/memo-parser/package.json index 723005d21b..b89c3c5ada 100644 --- a/memo-parser/package.json +++ b/memo-parser/package.json @@ -12,7 +12,8 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/import-js/eslint-plugin-import.git" + "url": "git+https://github.com/import-js/eslint-plugin-import.git", + "directory": "memo-parser" }, "keywords": [ "eslint", diff --git a/package.json b/package.json index 5c0af48543..be150064d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-import", - "version": "2.29.1", + "version": "2.30.0", "description": "Import with sanity.", "engines": { "node": ">=4" @@ -11,6 +11,7 @@ }, "files": [ "*.md", + "!{CONTRIBUTING,RELEASE}.md", "LICENSE", "docs", "lib", @@ -30,6 +31,9 @@ "test": "npm run tests-only", "test-compiled": "npm run prepublish && BABEL_ENV=testCompiled mocha --compilers js:babel-register tests/src", "test-all": "node --require babel-register ./scripts/testAll", + "test-examples": "npm run build && npm run test-example:legacy && npm run test-example:flat", + "test-example:legacy": "cd examples/legacy && npm install && npm run lint", + "test-example:flat": "cd examples/flat && npm install && npm run lint", "prepublishOnly": "safe-publish-latest && npm run build", "prepublish": "not-in-publish || npm run prepublishOnly", "preupdate:eslint-docs": "npm run build", @@ -82,13 +86,15 @@ "eslint-plugin-eslint-plugin": "^2.3.0", "eslint-plugin-import": "2.x", "eslint-plugin-json": "^2.1.2", + "find-babel-config": "=1.2.0", "fs-copy-file-sync": "^1.1.1", "glob": "^7.2.3", "in-publish": "^2.0.1", "jackspeak": "=2.1.1", + "jsonc-parser": "=3.2.0", "linklocal": "^2.8.2", "lodash.isarray": "^4.0.0", - "markdownlint-cli": "^0.38.0", + "markdownlint-cli": "~0.35", "mocha": "^3.5.3", "npm-which": "^3.0.1", "nyc": "^11.9.0", @@ -103,21 +109,22 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" }, "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", + "eslint-module-utils": "^2.9.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", "semver": "^6.3.1", "tsconfig-paths": "^3.15.0" } diff --git a/resolvers/node/index.js b/resolvers/node/index.js index 7f207fbf31..9e0e753cc7 100644 --- a/resolvers/node/index.js +++ b/resolvers/node/index.js @@ -8,26 +8,6 @@ const log = require('debug')('eslint-plugin-import:resolver:node'); exports.interfaceVersion = 2; -exports.resolve = function (source, file, config) { - log('Resolving:', source, 'from:', file); - let resolvedPath; - - if (isCoreModule(source)) { - log('resolved to core'); - return { found: true, path: null }; - } - - try { - const cachedFilter = function (pkg, dir) { return packageFilter(pkg, dir, config); }; - resolvedPath = resolve(source, opts(file, config, cachedFilter)); - log('Resolved to:', resolvedPath); - return { found: true, path: resolvedPath }; - } catch (err) { - log('resolve threw error:', err); - return { found: false }; - } -}; - function opts(file, config, packageFilter) { return Object.assign({ // more closely matches Node (#333) // plus 'mjs' for native modules! (#939) @@ -64,3 +44,23 @@ function packageFilter(pkg, dir, config) { } return pkg; } + +exports.resolve = function (source, file, config) { + log('Resolving:', source, 'from:', file); + let resolvedPath; + + if (isCoreModule(source)) { + log('resolved to core'); + return { found: true, path: null }; + } + + try { + const cachedFilter = function (pkg, dir) { return packageFilter(pkg, dir, config); }; + resolvedPath = resolve(source, opts(file, config, cachedFilter)); + log('Resolved to:', resolvedPath); + return { found: true, path: resolvedPath }; + } catch (err) { + log('resolve threw error:', err); + return { found: false }; + } +}; diff --git a/resolvers/node/package.json b/resolvers/node/package.json index bfaab40413..6f6999e6cb 100644 --- a/resolvers/node/package.json +++ b/resolvers/node/package.json @@ -13,7 +13,8 @@ }, "repository": { "type": "git", - "url": "https://github.com/import-js/eslint-plugin-import" + "url": "https://github.com/import-js/eslint-plugin-import", + "directory": "resolvers/node" }, "keywords": [ "eslint", diff --git a/resolvers/webpack/CHANGELOG.md b/resolvers/webpack/CHANGELOG.md index 4fed046b46..79b2837e3d 100644 --- a/resolvers/webpack/CHANGELOG.md +++ b/resolvers/webpack/CHANGELOG.md @@ -5,11 +5,15 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +## 0.13.9 - 2024-09-02 +- [refactor] simplify loop ([#3029], thanks [@fregante]) +- [meta] add `repository.directory` field +- [refactor] avoid hoisting, misc cleanup + ## 0.13.8 - 2023-10-22 - [refactor] use `hasown` instead of `has` - [deps] update `array.prototype.find`, `is-core-module`, `resolve` - ## 0.13.7 - 2023-08-19 - [fix] use the `dirname` of the `configPath` as `basedir` ([#2859]) @@ -178,6 +182,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Added - `interpret` configs (such as `.babel.js`). Thanks to [@gausie] for the initial PR ([#164], ages ago! 😅) and [@jquense] for tests ([#278]). +[#3029]: https://github.com/import-js/eslint-plugin-import/pull/3029 [#2287]: https://github.com/import-js/eslint-plugin-import/pull/2287 [#2023]: https://github.com/import-js/eslint-plugin-import/pull/2023 [#1967]: https://github.com/import-js/eslint-plugin-import/pull/1967 @@ -222,6 +227,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange [@benmvp]: https://github.com/benmvp [@daltones]: https://github.com/daltones [@echenley]: https://github.com/echenley +[@fregante]: https://github.com/fregante [@gausie]: https://github.com/gausie [@grahamb]: https://github.com/grahamb [@graingert]: https://github.com/graingert diff --git a/resolvers/webpack/index.js b/resolvers/webpack/index.js index 3ca2874dd8..83297cd185 100644 --- a/resolvers/webpack/index.js +++ b/resolvers/webpack/index.js @@ -3,216 +3,141 @@ const findRoot = require('find-root'); const path = require('path'); const isEqual = require('lodash/isEqual'); -const find = require('array.prototype.find'); const interpret = require('interpret'); -const fs = require('fs'); +const existsSync = require('fs').existsSync; const isCore = require('is-core-module'); const resolve = require('resolve/sync'); const semver = require('semver'); const hasOwn = require('hasown'); const isRegex = require('is-regex'); +const isArray = Array.isArray; +const keys = Object.keys; +const assign = Object.assign; const log = require('debug')('eslint-plugin-import:resolver:webpack'); exports.interfaceVersion = 2; -/** - * Find the full path to 'source', given 'file' as a full reference path. - * - * resolveImport('./foo', '/Users/ben/bar.js') => '/Users/ben/foo.js' - * @param {string} source - the module to resolve; i.e './some-module' - * @param {string} file - the importing file's full path; i.e. '/usr/local/bin/file.js' - * @param {object} settings - the webpack config file name, as well as cwd - * @example - * options: { - * // Path to the webpack config - * config: 'webpack.config.js', - * // Path to be used to determine where to resolve webpack from - * // (may differ from the cwd in some cases) - * cwd: process.cwd() - * } - * @return {string?} the resolved path to source, undefined if not resolved, or null - * if resolved to a non-FS resource (i.e. script tag at page load) - */ -exports.resolve = function (source, file, settings) { - - // strip loaders - const finalBang = source.lastIndexOf('!'); - if (finalBang >= 0) { - source = source.slice(finalBang + 1); - } - - // strip resource query - const finalQuestionMark = source.lastIndexOf('?'); - if (finalQuestionMark >= 0) { - source = source.slice(0, finalQuestionMark); - } - - let webpackConfig; - - const _configPath = settings && settings.config; - /** - * Attempt to set the current working directory. - * If none is passed, default to the `cwd` where the config is located. - */ - const cwd = settings && settings.cwd; - const configIndex = settings && settings['config-index']; - const env = settings && settings.env; - const argv = settings && typeof settings.argv !== 'undefined' ? settings.argv : {}; - let packageDir; - - let configPath = typeof _configPath === 'string' && _configPath.startsWith('.') - ? path.resolve(_configPath) - : _configPath; - - log('Config path from settings:', configPath); - - // see if we've got a config path, a config object, an array of config objects or a config function - if (!configPath || typeof configPath === 'string') { - - // see if we've got an absolute path - if (!configPath || !path.isAbsolute(configPath)) { - // if not, find ancestral package.json and use its directory as base for the path - packageDir = findRoot(path.resolve(file)); - if (!packageDir) { throw new Error('package not found above ' + file); } +function registerCompiler(moduleDescriptor) { + if (moduleDescriptor) { + if (typeof moduleDescriptor === 'string') { + require(moduleDescriptor); + } else if (!isArray(moduleDescriptor)) { + moduleDescriptor.register(require(moduleDescriptor.module)); + } else { + for (let i = 0; i < moduleDescriptor.length; i++) { + try { + registerCompiler(moduleDescriptor[i]); + break; + } catch (e) { + log('Failed to register compiler for moduleDescriptor[]:', i, moduleDescriptor); + } + } } + } +} - configPath = findConfigPath(configPath, packageDir); +function findConfigPath(configPath, packageDir) { + const extensions = keys(interpret.extensions).sort(function (a, b) { + return a === '.js' ? -1 : b === '.js' ? 1 : a.length - b.length; + }); + let extension; - log('Config path resolved to:', configPath); - if (configPath) { - try { - webpackConfig = require(configPath); - } catch (e) { - console.log('Error resolving webpackConfig', e); - throw e; + if (configPath) { + for (let i = extensions.length - 1; i >= 0 && !extension; i--) { + const maybeExtension = extensions[i]; + if (configPath.slice(-maybeExtension.length) === maybeExtension) { + extension = maybeExtension; } - } else { - log('No config path found relative to', file, '; using {}'); - webpackConfig = {}; } - if (webpackConfig && webpackConfig.default) { - log('Using ES6 module "default" key instead of module.exports.'); - webpackConfig = webpackConfig.default; + // see if we've got an absolute path + if (!path.isAbsolute(configPath)) { + configPath = path.join(packageDir, configPath); } - } else { - webpackConfig = configPath; - configPath = null; - } - - if (typeof webpackConfig === 'function') { - webpackConfig = webpackConfig(env, argv); - } - - if (Array.isArray(webpackConfig)) { - webpackConfig = webpackConfig.map((cfg) => { - if (typeof cfg === 'function') { - return cfg(env, argv); + for (let i = 0; i < extensions.length && !extension; i++) { + const maybeExtension = extensions[i]; + const maybePath = path.resolve( + path.join(packageDir, 'webpack.config' + maybeExtension) + ); + if (existsSync(maybePath)) { + configPath = maybePath; + extension = maybeExtension; } - - return cfg; - }); - - if (typeof configIndex !== 'undefined' && webpackConfig.length > configIndex) { - webpackConfig = webpackConfig[configIndex]; - } else { - webpackConfig = find(webpackConfig, function findFirstWithResolve(config) { - return !!config.resolve; - }); } } - if (typeof webpackConfig.then === 'function') { - webpackConfig = {}; - - console.warn('Webpack config returns a `Promise`; that signature is not supported at the moment. Using empty object instead.'); - } - - if (webpackConfig == null) { - webpackConfig = {}; - - console.warn('No webpack configuration with a "resolve" field found. Using empty object instead.'); - } + registerCompiler(interpret.extensions[extension]); + return configPath; +} - log('Using config: ', webpackConfig); +function findExternal(source, externals, context, resolveSync) { + if (!externals) { return false; } - const resolveSync = getResolveSync(configPath, webpackConfig, cwd); + // string match + if (typeof externals === 'string') { return source === externals; } - // externals - if (findExternal(source, webpackConfig.externals, path.dirname(file), resolveSync)) { - return { found: true, path: null }; + // array: recurse + if (isArray(externals)) { + return externals.some(function (e) { return findExternal(source, e, context, resolveSync); }); } - // otherwise, resolve "normally" - - try { - return { found: true, path: resolveSync(path.dirname(file), source) }; - } catch (err) { - if (isCore(source)) { - return { found: true, path: null }; - } - - log('Error during module resolution:', err); - return { found: false }; + if (isRegex(externals)) { + return externals.test(source); } -}; -const MAX_CACHE = 10; -const _cache = []; -function getResolveSync(configPath, webpackConfig, cwd) { - const cacheKey = { configPath, webpackConfig }; - let cached = find(_cache, function (entry) { return isEqual(entry.key, cacheKey); }); - if (!cached) { - cached = { - key: cacheKey, - value: createResolveSync(configPath, webpackConfig, cwd), + if (typeof externals === 'function') { + let functionExternalFound = false; + const callback = function (err, value) { + if (err) { + functionExternalFound = false; + } else { + functionExternalFound = findExternal(source, value, context, resolveSync); + } }; - // put in front and pop last item - if (_cache.unshift(cached) > MAX_CACHE) { - _cache.pop(); + // - for prior webpack 5, 'externals function' uses 3 arguments + // - for webpack 5, the count of arguments is less than 3 + if (externals.length === 3) { + externals.call(null, context, source, callback); + } else { + const ctx = { + context, + request: source, + contextInfo: { + issuer: '', + issuerLayer: null, + compiler: '', + }, + getResolve: () => (resolveContext, requestToResolve, cb) => { + if (cb) { + try { + cb(null, resolveSync(resolveContext, requestToResolve)); + } catch (e) { + cb(e); + } + } else { + log('getResolve without callback not supported'); + return Promise.reject(new Error('Not supported')); + } + }, + }; + const result = externals.call(null, ctx, callback); + // todo handling Promise object (using synchronous-promise package?) + if (result && typeof result.then === 'function') { + log('Asynchronous functions for externals not supported'); + } } + return functionExternalFound; } - return cached.value; -} - -function createResolveSync(configPath, webpackConfig, cwd) { - let webpackRequire; - let basedir = null; - - if (typeof configPath === 'string') { - // This can be changed via the settings passed in when defining the resolver - basedir = cwd || path.dirname(configPath); - log(`Attempting to load webpack path from ${basedir}`); - } - - try { - // Attempt to resolve webpack from the given `basedir` - const webpackFilename = resolve('webpack', { basedir, preserveSymlinks: false }); - const webpackResolveOpts = { basedir: path.dirname(webpackFilename), preserveSymlinks: false }; - - webpackRequire = function (id) { - return require(resolve(id, webpackResolveOpts)); - }; - } catch (e) { - // Something has gone wrong (or we're in a test). Use our own bundled - // enhanced-resolve. - log('Using bundled enhanced-resolve.'); - webpackRequire = require; - } - - const enhancedResolvePackage = webpackRequire('enhanced-resolve/package.json'); - const enhancedResolveVersion = enhancedResolvePackage.version; - log('enhanced-resolve version:', enhancedResolveVersion); - const resolveConfig = webpackConfig.resolve || {}; - - if (semver.major(enhancedResolveVersion) >= 2) { - return createWebpack2ResolveSync(webpackRequire, resolveConfig); + // else, vanilla object + for (const key in externals) { + if (hasOwn(externals, key) && source === key) { + return true; + } } - - return createWebpack1ResolveSync(webpackRequire, resolveConfig, webpackConfig.plugins); + return false; } /** @@ -231,17 +156,39 @@ const webpack2DefaultResolveConfig = { function createWebpack2ResolveSync(webpackRequire, resolveConfig) { const EnhancedResolve = webpackRequire('enhanced-resolve'); - return EnhancedResolve.create.sync(Object.assign({}, webpack2DefaultResolveConfig, resolveConfig)); + return EnhancedResolve.create.sync(assign({}, webpack2DefaultResolveConfig, resolveConfig)); } /** * webpack 1 defaults: https://webpack.github.io/docs/configuration.html#resolve-packagemains - * @type {Array} + * @type {string[]} */ const webpack1DefaultMains = [ - 'webpack', 'browser', 'web', 'browserify', ['jam', 'main'], 'main', + 'webpack', + 'browser', + 'web', + 'browserify', + ['jam', 'main'], + 'main', ]; +/* eslint-disable */ +// from https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L365 +function makeRootPlugin(ModulesInRootPlugin, name, root) { + if (typeof root === 'string') { + return new ModulesInRootPlugin(name, root); + } + if (isArray(root)) { + return function () { + root.forEach(function (root) { + this.apply(new ModulesInRootPlugin(name, root)); + }, this); + }; + } + return function () {}; +} +/* eslint-enable */ + // adapted from tests & // https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L322 function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { @@ -291,7 +238,7 @@ function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { if ( plugin.constructor && plugin.constructor.name === 'ResolverPlugin' - && Array.isArray(plugin.plugins) + && isArray(plugin.plugins) ) { resolvePlugins.push.apply(resolvePlugins, plugin.plugins); } @@ -305,147 +252,207 @@ function createWebpack1ResolveSync(webpackRequire, resolveConfig, plugins) { }; } -/* eslint-disable */ -// from https://github.com/webpack/webpack/blob/v1.13.0/lib/WebpackOptionsApply.js#L365 -function makeRootPlugin(ModulesInRootPlugin, name, root) { - if (typeof root === 'string') { - return new ModulesInRootPlugin(name, root); - } else if (Array.isArray(root)) { - return function() { - root.forEach(function (root) { - this.apply(new ModulesInRootPlugin(name, root)); - }, this); +function createResolveSync(configPath, webpackConfig, cwd) { + let webpackRequire; + let basedir = null; + + if (typeof configPath === 'string') { + // This can be changed via the settings passed in when defining the resolver + basedir = cwd || path.dirname(configPath); + log(`Attempting to load webpack path from ${basedir}`); + } + + try { + // Attempt to resolve webpack from the given `basedir` + const webpackFilename = resolve('webpack', { basedir, preserveSymlinks: false }); + const webpackResolveOpts = { basedir: path.dirname(webpackFilename), preserveSymlinks: false }; + + webpackRequire = function (id) { + return require(resolve(id, webpackResolveOpts)); }; + } catch (e) { + // Something has gone wrong (or we're in a test). Use our own bundled + // enhanced-resolve. + log('Using bundled enhanced-resolve.'); + webpackRequire = require; } - return function () {}; -} -/* eslint-enable */ -function findExternal(source, externals, context, resolveSync) { - if (!externals) { return false; } + const enhancedResolvePackage = webpackRequire('enhanced-resolve/package.json'); + const enhancedResolveVersion = enhancedResolvePackage.version; + log('enhanced-resolve version:', enhancedResolveVersion); - // string match - if (typeof externals === 'string') { return source === externals; } + const resolveConfig = webpackConfig.resolve || {}; - // array: recurse - if (Array.isArray(externals)) { - return externals.some(function (e) { return findExternal(source, e, context, resolveSync); }); + if (semver.major(enhancedResolveVersion) >= 2) { + return createWebpack2ResolveSync(webpackRequire, resolveConfig); } - if (isRegex(externals)) { - return externals.test(source); - } + return createWebpack1ResolveSync(webpackRequire, resolveConfig, webpackConfig.plugins); +} - if (typeof externals === 'function') { - let functionExternalFound = false; - const callback = function (err, value) { - if (err) { - functionExternalFound = false; - } else { - functionExternalFound = findExternal(source, value, context, resolveSync); - } - }; - // - for prior webpack 5, 'externals function' uses 3 arguments - // - for webpack 5, the count of arguments is less than 3 - if (externals.length === 3) { - externals.call(null, context, source, callback); - } else { - const ctx = { - context, - request: source, - contextInfo: { - issuer: '', - issuerLayer: null, - compiler: '', - }, - getResolve: () => (resolveContext, requestToResolve, cb) => { - if (cb) { - try { - cb(null, resolveSync(resolveContext, requestToResolve)); - } catch (e) { - cb(e); - } - } else { - log('getResolve without callback not supported'); - return Promise.reject(new Error('Not supported')); - } - }, - }; - const result = externals.call(null, ctx, callback); - // todo handling Promise object (using synchronous-promise package?) - if (result && typeof result.then === 'function') { - log('Asynchronous functions for externals not supported'); - } +const MAX_CACHE = 10; +const _cache = []; +function getResolveSync(configPath, webpackConfig, cwd) { + const cacheKey = { configPath, webpackConfig }; + for (let i = 0; i < _cache.length; i++) { + if (isEqual(_cache[i].key, cacheKey)) { + return _cache[i].value; } - return functionExternalFound; } - // else, vanilla object - for (const key in externals) { - if (!hasOwn(externals, key)) { continue; } - if (source === key) { return true; } + const cached = { + key: cacheKey, + value: createResolveSync(configPath, webpackConfig, cwd), + }; + // put in front and pop last item + if (_cache.unshift(cached) > MAX_CACHE) { + _cache.pop(); } - return false; + return cached.value; } -function findConfigPath(configPath, packageDir) { - const extensions = Object.keys(interpret.extensions).sort(function (a, b) { - return a === '.js' ? -1 : b === '.js' ? 1 : a.length - b.length; - }); - let extension; +/** + * Find the full path to 'source', given 'file' as a full reference path. + * + * resolveImport('./foo', '/Users/ben/bar.js') => '/Users/ben/foo.js' + * @param {string} source - the module to resolve; i.e './some-module' + * @param {string} file - the importing file's full path; i.e. '/usr/local/bin/file.js' + * @param {object} settings - the webpack config file name, as well as cwd + * @example + * options: { + * // Path to the webpack config + * config: 'webpack.config.js', + * // Path to be used to determine where to resolve webpack from + * // (may differ from the cwd in some cases) + * cwd: process.cwd() + * } + * @return {string?} the resolved path to source, undefined if not resolved, or null + * if resolved to a non-FS resource (i.e. script tag at page load) + */ +exports.resolve = function (source, file, settings) { - if (configPath) { - // extensions is not reused below, so safe to mutate it here. - extensions.reverse(); - extensions.forEach(function (maybeExtension) { - if (extension) { - return; - } + // strip loaders + const finalBang = source.lastIndexOf('!'); + if (finalBang >= 0) { + source = source.slice(finalBang + 1); + } - if (configPath.substr(-maybeExtension.length) === maybeExtension) { - extension = maybeExtension; - } - }); + // strip resource query + const finalQuestionMark = source.lastIndexOf('?'); + if (finalQuestionMark >= 0) { + source = source.slice(0, finalQuestionMark); + } + + let webpackConfig; + + const _configPath = settings && settings.config; + /** + * Attempt to set the current working directory. + * If none is passed, default to the `cwd` where the config is located. + */ + const cwd = settings && settings.cwd; + const configIndex = settings && settings['config-index']; + const env = settings && settings.env; + const argv = settings && typeof settings.argv !== 'undefined' ? settings.argv : {}; + let packageDir; + + let configPath = typeof _configPath === 'string' && _configPath.startsWith('.') + ? path.resolve(_configPath) + : _configPath; + + log('Config path from settings:', configPath); + + // see if we've got a config path, a config object, an array of config objects or a config function + if (!configPath || typeof configPath === 'string') { // see if we've got an absolute path - if (!path.isAbsolute(configPath)) { - configPath = path.join(packageDir, configPath); + if (!configPath || !path.isAbsolute(configPath)) { + // if not, find ancestral package.json and use its directory as base for the path + packageDir = findRoot(path.resolve(file)); + if (!packageDir) { throw new Error('package not found above ' + file); } } - } else { - extensions.forEach(function (maybeExtension) { - if (extension) { - return; - } - const maybePath = path.resolve( - path.join(packageDir, 'webpack.config' + maybeExtension) - ); - if (fs.existsSync(maybePath)) { - configPath = maybePath; - extension = maybeExtension; + configPath = findConfigPath(configPath, packageDir); + + log('Config path resolved to:', configPath); + if (configPath) { + try { + webpackConfig = require(configPath); + } catch (e) { + console.log('Error resolving webpackConfig', e); + throw e; } - }); + } else { + log('No config path found relative to', file, '; using {}'); + webpackConfig = {}; + } + + if (webpackConfig && webpackConfig.default) { + log('Using ES6 module "default" key instead of module.exports.'); + webpackConfig = webpackConfig.default; + } + + } else { + webpackConfig = configPath; + configPath = null; } - registerCompiler(interpret.extensions[extension]); - return configPath; -} + if (typeof webpackConfig === 'function') { + webpackConfig = webpackConfig(env, argv); + } -function registerCompiler(moduleDescriptor) { - if (moduleDescriptor) { - if (typeof moduleDescriptor === 'string') { - require(moduleDescriptor); - } else if (!Array.isArray(moduleDescriptor)) { - moduleDescriptor.register(require(moduleDescriptor.module)); + if (isArray(webpackConfig)) { + webpackConfig = webpackConfig.map((cfg) => { + if (typeof cfg === 'function') { + return cfg(env, argv); + } + + return cfg; + }); + + if (typeof configIndex !== 'undefined' && webpackConfig.length > configIndex) { + webpackConfig = webpackConfig[configIndex]; } else { - for (let i = 0; i < moduleDescriptor.length; i++) { - try { - registerCompiler(moduleDescriptor[i]); + for (let i = 0; i < webpackConfig.length; i++) { + if (webpackConfig[i].resolve) { + webpackConfig = webpackConfig[i]; break; - } catch (e) { - log('Failed to register compiler for moduleDescriptor[]:', i, moduleDescriptor); } } } } -} + + if (typeof webpackConfig.then === 'function') { + webpackConfig = {}; + + console.warn('Webpack config returns a `Promise`; that signature is not supported at the moment. Using empty object instead.'); + } + + if (webpackConfig == null) { + webpackConfig = {}; + + console.warn('No webpack configuration with a "resolve" field found. Using empty object instead.'); + } + + log('Using config: ', webpackConfig); + + const resolveSync = getResolveSync(configPath, webpackConfig, cwd); + + // externals + if (findExternal(source, webpackConfig.externals, path.dirname(file), resolveSync)) { + return { found: true, path: null }; + } + + // otherwise, resolve "normally" + + try { + return { found: true, path: resolveSync(path.dirname(file), source) }; + } catch (err) { + if (isCore(source)) { + return { found: true, path: null }; + } + + log('Error during module resolution:', err); + return { found: false }; + } +}; diff --git a/resolvers/webpack/package.json b/resolvers/webpack/package.json index 3fa47d9362..60e5c900f0 100644 --- a/resolvers/webpack/package.json +++ b/resolvers/webpack/package.json @@ -1,6 +1,6 @@ { "name": "eslint-import-resolver-webpack", - "version": "0.13.8", + "version": "0.13.9", "description": "Resolve paths to dependencies, given a webpack.config.js. Plugin for eslint-plugin-import.", "main": "index.js", "scripts": { @@ -14,7 +14,8 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/import-js/eslint-plugin-import.git" + "url": "git+https://github.com/import-js/eslint-plugin-import.git", + "directory": "resolvers/webpack" }, "keywords": [ "eslint-plugin-import", @@ -30,7 +31,6 @@ }, "homepage": "https://github.com/import-js/eslint-plugin-import/tree/HEAD/resolvers/webpack", "dependencies": { - "array.prototype.find": "^2.2.2", "debug": "^3.2.7", "enhanced-resolve": "^0.9.1", "find-root": "^1.1.0", diff --git a/src/ExportMap.js b/src/ExportMap.js deleted file mode 100644 index f61d3c170a..0000000000 --- a/src/ExportMap.js +++ /dev/null @@ -1,826 +0,0 @@ -import fs from 'fs'; -import { dirname } from 'path'; - -import doctrine from 'doctrine'; - -import debug from 'debug'; - -import { SourceCode } from 'eslint'; - -import parse from 'eslint-module-utils/parse'; -import visit from 'eslint-module-utils/visit'; -import resolve from 'eslint-module-utils/resolve'; -import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'; - -import { hashObject } from 'eslint-module-utils/hash'; -import * as unambiguous from 'eslint-module-utils/unambiguous'; - -import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'; - -import includes from 'array-includes'; - -let ts; - -const log = debug('eslint-plugin-import:ExportMap'); - -const exportCache = new Map(); -const tsconfigCache = new Map(); - -export default class ExportMap { - constructor(path) { - this.path = path; - this.namespace = new Map(); - // todo: restructure to key on path, value is resolver + map of names - this.reexports = new Map(); - /** - * star-exports - * @type {Set} of () => ExportMap - */ - this.dependencies = new Set(); - /** - * dependencies of this module that are not explicitly re-exported - * @type {Map} from path = () => ExportMap - */ - this.imports = new Map(); - this.errors = []; - /** - * type {'ambiguous' | 'Module' | 'Script'} - */ - this.parseGoal = 'ambiguous'; - } - - get hasDefault() { return this.get('default') != null; } // stronger than this.has - - get size() { - let size = this.namespace.size + this.reexports.size; - this.dependencies.forEach((dep) => { - const d = dep(); - // CJS / ignored dependencies won't exist (#717) - if (d == null) { return; } - size += d.size; - }); - return size; - } - - /** - * Note that this does not check explicitly re-exported names for existence - * in the base namespace, but it will expand all `export * from '...'` exports - * if not found in the explicit namespace. - * @param {string} name - * @return {Boolean} true if `name` is exported by this module. - */ - has(name) { - if (this.namespace.has(name)) { return true; } - if (this.reexports.has(name)) { return true; } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep(); - - // todo: report as unresolved? - if (!innerMap) { continue; } - - if (innerMap.has(name)) { return true; } - } - } - - return false; - } - - /** - * ensure that imported name fully resolves. - * @param {string} name - * @return {{ found: boolean, path: ExportMap[] }} - */ - hasDeep(name) { - if (this.namespace.has(name)) { return { found: true, path: [this] }; } - - if (this.reexports.has(name)) { - const reexports = this.reexports.get(name); - const imported = reexports.getImport(); - - // if import is ignored, return explicit 'null' - if (imported == null) { return { found: true, path: [this] }; } - - // safeguard against cycles, only if name matches - if (imported.path === this.path && reexports.local === name) { - return { found: false, path: [this] }; - } - - const deep = imported.hasDeep(reexports.local); - deep.path.unshift(this); - - return deep; - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep(); - if (innerMap == null) { return { found: true, path: [this] }; } - // todo: report as unresolved? - if (!innerMap) { continue; } - - // safeguard against cycles - if (innerMap.path === this.path) { continue; } - - const innerValue = innerMap.hasDeep(name); - if (innerValue.found) { - innerValue.path.unshift(this); - return innerValue; - } - } - } - - return { found: false, path: [this] }; - } - - get(name) { - if (this.namespace.has(name)) { return this.namespace.get(name); } - - if (this.reexports.has(name)) { - const reexports = this.reexports.get(name); - const imported = reexports.getImport(); - - // if import is ignored, return explicit 'null' - if (imported == null) { return null; } - - // safeguard against cycles, only if name matches - if (imported.path === this.path && reexports.local === name) { return undefined; } - - return imported.get(reexports.local); - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep(); - // todo: report as unresolved? - if (!innerMap) { continue; } - - // safeguard against cycles - if (innerMap.path === this.path) { continue; } - - const innerValue = innerMap.get(name); - if (innerValue !== undefined) { return innerValue; } - } - } - - return undefined; - } - - forEach(callback, thisArg) { - this.namespace.forEach((v, n) => { callback.call(thisArg, v, n, this); }); - - this.reexports.forEach((reexports, name) => { - const reexported = reexports.getImport(); - // can't look up meta for ignored re-exports (#348) - callback.call(thisArg, reexported && reexported.get(reexports.local), name, this); - }); - - this.dependencies.forEach((dep) => { - const d = dep(); - // CJS / ignored dependencies won't exist (#717) - if (d == null) { return; } - - d.forEach((v, n) => { - if (n !== 'default') { - callback.call(thisArg, v, n, this); - } - }); - }); - } - - // todo: keys, values, entries? - - reportErrors(context, declaration) { - const msg = this.errors - .map((e) => `${e.message} (${e.lineNumber}:${e.column})`) - .join(', '); - context.report({ - node: declaration.source, - message: `Parse errors in imported module '${declaration.source.value}': ${msg}`, - }); - } -} - -/** - * parse docs from the first node that has leading comments - */ -function captureDoc(source, docStyleParsers, ...nodes) { - const metadata = {}; - - // 'some' short-circuits on first 'true' - nodes.some((n) => { - try { - - let leadingComments; - - // n.leadingComments is legacy `attachComments` behavior - if ('leadingComments' in n) { - leadingComments = n.leadingComments; - } else if (n.range) { - leadingComments = source.getCommentsBefore(n); - } - - if (!leadingComments || leadingComments.length === 0) { return false; } - - for (const name in docStyleParsers) { - const doc = docStyleParsers[name](leadingComments); - if (doc) { - metadata.doc = doc; - } - } - - return true; - } catch (err) { - return false; - } - }); - - return metadata; -} - -const availableDocStyleParsers = { - jsdoc: captureJsDoc, - tomdoc: captureTomDoc, -}; - -/** - * parse JSDoc from leading comments - * @param {object[]} comments - * @return {{ doc: object }} - */ -function captureJsDoc(comments) { - let doc; - - // capture XSDoc - comments.forEach((comment) => { - // skip non-block comments - if (comment.type !== 'Block') { return; } - try { - doc = doctrine.parse(comment.value, { unwrap: true }); - } catch (err) { - /* don't care, for now? maybe add to `errors?` */ - } - }); - - return doc; -} - -/** - * parse TomDoc section from comments - */ -function captureTomDoc(comments) { - // collect lines up to first paragraph break - const lines = []; - for (let i = 0; i < comments.length; i++) { - const comment = comments[i]; - if (comment.value.match(/^\s*$/)) { break; } - lines.push(comment.value.trim()); - } - - // return doctrine-like object - const statusMatch = lines.join(' ').match(/^(Public|Internal|Deprecated):\s*(.+)/); - if (statusMatch) { - return { - description: statusMatch[2], - tags: [{ - title: statusMatch[1].toLowerCase(), - description: statusMatch[2], - }], - }; - } -} - -const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']); - -ExportMap.get = function (source, context) { - const path = resolve(source, context); - if (path == null) { return null; } - - return ExportMap.for(childContext(path, context)); -}; - -ExportMap.for = function (context) { - const { path } = context; - - const cacheKey = context.cacheKey || hashObject(context).digest('hex'); - let exportMap = exportCache.get(cacheKey); - - // return cached ignore - if (exportMap === null) { return null; } - - const stats = fs.statSync(path); - if (exportMap != null) { - // date equality check - if (exportMap.mtime - stats.mtime === 0) { - return exportMap; - } - // future: check content equality? - } - - // check valid extensions first - if (!hasValidExtension(path, context)) { - exportCache.set(cacheKey, null); - return null; - } - - // check for and cache ignore - if (isIgnored(path, context)) { - log('ignored path due to ignore settings:', path); - exportCache.set(cacheKey, null); - return null; - } - - const content = fs.readFileSync(path, { encoding: 'utf8' }); - - // check for and cache unambiguous modules - if (!unambiguous.test(content)) { - log('ignored path due to unambiguous regex:', path); - exportCache.set(cacheKey, null); - return null; - } - - log('cache miss', cacheKey, 'for path', path); - exportMap = ExportMap.parse(path, content, context); - - // ambiguous modules return null - if (exportMap == null) { - log('ignored path due to ambiguous parse:', path); - exportCache.set(cacheKey, null); - return null; - } - - exportMap.mtime = stats.mtime; - - exportCache.set(cacheKey, exportMap); - return exportMap; -}; - -ExportMap.parse = function (path, content, context) { - const m = new ExportMap(path); - const isEsModuleInteropTrue = isEsModuleInterop(); - - let ast; - let visitorKeys; - try { - const result = parse(path, content, context); - ast = result.ast; - visitorKeys = result.visitorKeys; - } catch (err) { - m.errors.push(err); - return m; // can't continue - } - - m.visitorKeys = visitorKeys; - - let hasDynamicImports = false; - - function processDynamicImport(source) { - hasDynamicImports = true; - if (source.type !== 'Literal') { - return null; - } - const p = remotePath(source.value); - if (p == null) { - return null; - } - const importedSpecifiers = new Set(); - importedSpecifiers.add('ImportNamespaceSpecifier'); - const getter = thunkFor(p, context); - m.imports.set(p, { - getter, - declarations: new Set([{ - source: { - // capturing actual node reference holds full AST in memory! - value: source.value, - loc: source.loc, - }, - importedSpecifiers, - dynamic: true, - }]), - }); - } - - visit(ast, visitorKeys, { - ImportExpression(node) { - processDynamicImport(node.source); - }, - CallExpression(node) { - if (node.callee.type === 'Import') { - processDynamicImport(node.arguments[0]); - } - }, - }); - - const unambiguouslyESM = unambiguous.isModule(ast); - if (!unambiguouslyESM && !hasDynamicImports) { return null; } - - const docstyle = context.settings && context.settings['import/docstyle'] || ['jsdoc']; - const docStyleParsers = {}; - docstyle.forEach((style) => { - docStyleParsers[style] = availableDocStyleParsers[style]; - }); - - // attempt to collect module doc - if (ast.comments) { - ast.comments.some((c) => { - if (c.type !== 'Block') { return false; } - try { - const doc = doctrine.parse(c.value, { unwrap: true }); - if (doc.tags.some((t) => t.title === 'module')) { - m.doc = doc; - return true; - } - } catch (err) { /* ignore */ } - return false; - }); - } - - const namespaces = new Map(); - - function remotePath(value) { - return resolve.relative(value, path, context.settings); - } - - function resolveImport(value) { - const rp = remotePath(value); - if (rp == null) { return null; } - return ExportMap.for(childContext(rp, context)); - } - - function getNamespace(identifier) { - if (!namespaces.has(identifier.name)) { return; } - - return function () { - return resolveImport(namespaces.get(identifier.name)); - }; - } - - function addNamespace(object, identifier) { - const nsfn = getNamespace(identifier); - if (nsfn) { - Object.defineProperty(object, 'namespace', { get: nsfn }); - } - - return object; - } - - function processSpecifier(s, n, m) { - const nsource = n.source && n.source.value; - const exportMeta = {}; - let local; - - switch (s.type) { - case 'ExportDefaultSpecifier': - if (!nsource) { return; } - local = 'default'; - break; - case 'ExportNamespaceSpecifier': - m.namespace.set(s.exported.name, Object.defineProperty(exportMeta, 'namespace', { - get() { return resolveImport(nsource); }, - })); - return; - case 'ExportAllDeclaration': - m.namespace.set(s.exported.name || s.exported.value, addNamespace(exportMeta, s.source.value)); - return; - case 'ExportSpecifier': - if (!n.source) { - m.namespace.set(s.exported.name || s.exported.value, addNamespace(exportMeta, s.local)); - return; - } - // else falls through - default: - local = s.local.name; - break; - } - - // todo: JSDoc - m.reexports.set(s.exported.name, { local, getImport: () => resolveImport(nsource) }); - } - - function captureDependencyWithSpecifiers(n) { - // import type { Foo } (TS and Flow); import typeof { Foo } (Flow) - const declarationIsType = n.importKind === 'type' || n.importKind === 'typeof'; - // import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and - // shouldn't be considered to be just importing types - let specifiersOnlyImportingTypes = n.specifiers.length > 0; - const importedSpecifiers = new Set(); - n.specifiers.forEach((specifier) => { - if (specifier.type === 'ImportSpecifier') { - importedSpecifiers.add(specifier.imported.name || specifier.imported.value); - } else if (supportedImportTypes.has(specifier.type)) { - importedSpecifiers.add(specifier.type); - } - - // import { type Foo } (Flow); import { typeof Foo } (Flow) - specifiersOnlyImportingTypes = specifiersOnlyImportingTypes - && (specifier.importKind === 'type' || specifier.importKind === 'typeof'); - }); - captureDependency(n, declarationIsType || specifiersOnlyImportingTypes, importedSpecifiers); - } - - function captureDependency({ source }, isOnlyImportingTypes, importedSpecifiers = new Set()) { - if (source == null) { return null; } - - const p = remotePath(source.value); - if (p == null) { return null; } - - const declarationMetadata = { - // capturing actual node reference holds full AST in memory! - source: { value: source.value, loc: source.loc }, - isOnlyImportingTypes, - importedSpecifiers, - }; - - const existing = m.imports.get(p); - if (existing != null) { - existing.declarations.add(declarationMetadata); - return existing.getter; - } - - const getter = thunkFor(p, context); - m.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }); - return getter; - } - - const source = makeSourceCode(content, ast); - - function readTsConfig(context) { - const tsconfigInfo = tsConfigLoader({ - cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), - getEnv: (key) => process.env[key], - }); - try { - if (tsconfigInfo.tsConfigPath !== undefined) { - // Projects not using TypeScript won't have `typescript` installed. - if (!ts) { ts = require('typescript'); } // eslint-disable-line import/no-extraneous-dependencies - - const configFile = ts.readConfigFile(tsconfigInfo.tsConfigPath, ts.sys.readFile); - return ts.parseJsonConfigFileContent( - configFile.config, - ts.sys, - dirname(tsconfigInfo.tsConfigPath), - ); - } - } catch (e) { - // Catch any errors - } - - return null; - } - - function isEsModuleInterop() { - const cacheKey = hashObject({ - tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, - }).digest('hex'); - let tsConfig = tsconfigCache.get(cacheKey); - if (typeof tsConfig === 'undefined') { - tsConfig = readTsConfig(context); - tsconfigCache.set(cacheKey, tsConfig); - } - - return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false; - } - - ast.body.forEach(function (n) { - if (n.type === 'ExportDefaultDeclaration') { - const exportMeta = captureDoc(source, docStyleParsers, n); - if (n.declaration.type === 'Identifier') { - addNamespace(exportMeta, n.declaration); - } - m.namespace.set('default', exportMeta); - return; - } - - if (n.type === 'ExportAllDeclaration') { - const getter = captureDependency(n, n.exportKind === 'type'); - if (getter) { m.dependencies.add(getter); } - if (n.exported) { - processSpecifier(n, n.exported, m); - } - return; - } - - // capture namespaces in case of later export - if (n.type === 'ImportDeclaration') { - captureDependencyWithSpecifiers(n); - - const ns = n.specifiers.find((s) => s.type === 'ImportNamespaceSpecifier'); - if (ns) { - namespaces.set(ns.local.name, n.source.value); - } - return; - } - - if (n.type === 'ExportNamedDeclaration') { - captureDependencyWithSpecifiers(n); - - // capture declaration - if (n.declaration != null) { - switch (n.declaration.type) { - case 'FunctionDeclaration': - case 'ClassDeclaration': - case 'TypeAlias': // flowtype with babel-eslint parser - case 'InterfaceDeclaration': - case 'DeclareFunction': - case 'TSDeclareFunction': - case 'TSEnumDeclaration': - case 'TSTypeAliasDeclaration': - case 'TSInterfaceDeclaration': - case 'TSAbstractClassDeclaration': - case 'TSModuleDeclaration': - m.namespace.set(n.declaration.id.name, captureDoc(source, docStyleParsers, n)); - break; - case 'VariableDeclaration': - n.declaration.declarations.forEach((d) => { - recursivePatternCapture( - d.id, - (id) => m.namespace.set(id.name, captureDoc(source, docStyleParsers, d, n)), - ); - }); - break; - default: - } - } - - n.specifiers.forEach((s) => processSpecifier(s, n, m)); - } - - const exports = ['TSExportAssignment']; - if (isEsModuleInteropTrue) { - exports.push('TSNamespaceExportDeclaration'); - } - - // This doesn't declare anything, but changes what's being exported. - if (includes(exports, n.type)) { - const exportedName = n.type === 'TSNamespaceExportDeclaration' - ? (n.id || n.name).name - : n.expression && n.expression.name || n.expression.id && n.expression.id.name || null; - const declTypes = [ - 'VariableDeclaration', - 'ClassDeclaration', - 'TSDeclareFunction', - 'TSEnumDeclaration', - 'TSTypeAliasDeclaration', - 'TSInterfaceDeclaration', - 'TSAbstractClassDeclaration', - 'TSModuleDeclaration', - ]; - const exportedDecls = ast.body.filter(({ type, id, declarations }) => includes(declTypes, type) && ( - id && id.name === exportedName || declarations && declarations.find((d) => d.id.name === exportedName) - )); - if (exportedDecls.length === 0) { - // Export is not referencing any local declaration, must be re-exporting - m.namespace.set('default', captureDoc(source, docStyleParsers, n)); - return; - } - if ( - isEsModuleInteropTrue // esModuleInterop is on in tsconfig - && !m.namespace.has('default') // and default isn't added already - ) { - m.namespace.set('default', {}); // add default export - } - exportedDecls.forEach((decl) => { - if (decl.type === 'TSModuleDeclaration') { - if (decl.body && decl.body.type === 'TSModuleDeclaration') { - m.namespace.set(decl.body.id.name, captureDoc(source, docStyleParsers, decl.body)); - } else if (decl.body && decl.body.body) { - decl.body.body.forEach((moduleBlockNode) => { - // Export-assignment exports all members in the namespace, - // explicitly exported or not. - const namespaceDecl = moduleBlockNode.type === 'ExportNamedDeclaration' - ? moduleBlockNode.declaration - : moduleBlockNode; - - if (!namespaceDecl) { - // TypeScript can check this for us; we needn't - } else if (namespaceDecl.type === 'VariableDeclaration') { - namespaceDecl.declarations.forEach((d) => recursivePatternCapture(d.id, (id) => m.namespace.set( - id.name, - captureDoc(source, docStyleParsers, decl, namespaceDecl, moduleBlockNode), - )), - ); - } else { - m.namespace.set( - namespaceDecl.id.name, - captureDoc(source, docStyleParsers, moduleBlockNode)); - } - }); - } - } else { - // Export as default - m.namespace.set('default', captureDoc(source, docStyleParsers, decl)); - } - }); - } - }); - - if ( - isEsModuleInteropTrue // esModuleInterop is on in tsconfig - && m.namespace.size > 0 // anything is exported - && !m.namespace.has('default') // and default isn't added already - ) { - m.namespace.set('default', {}); // add default export - } - - if (unambiguouslyESM) { - m.parseGoal = 'Module'; - } - return m; -}; - -/** - * The creation of this closure is isolated from other scopes - * to avoid over-retention of unrelated variables, which has - * caused memory leaks. See #1266. - */ -function thunkFor(p, context) { - return () => ExportMap.for(childContext(p, context)); -} - -/** - * Traverse a pattern/identifier node, calling 'callback' - * for each leaf identifier. - * @param {node} pattern - * @param {Function} callback - * @return {void} - */ -export function recursivePatternCapture(pattern, callback) { - switch (pattern.type) { - case 'Identifier': // base case - callback(pattern); - break; - - case 'ObjectPattern': - pattern.properties.forEach((p) => { - if (p.type === 'ExperimentalRestProperty' || p.type === 'RestElement') { - callback(p.argument); - return; - } - recursivePatternCapture(p.value, callback); - }); - break; - - case 'ArrayPattern': - pattern.elements.forEach((element) => { - if (element == null) { return; } - if (element.type === 'ExperimentalRestProperty' || element.type === 'RestElement') { - callback(element.argument); - return; - } - recursivePatternCapture(element, callback); - }); - break; - - case 'AssignmentPattern': - callback(pattern.left); - break; - default: - } -} - -let parserOptionsHash = ''; -let prevParserOptions = ''; -let settingsHash = ''; -let prevSettings = ''; -/** - * don't hold full context object in memory, just grab what we need. - * also calculate a cacheKey, where parts of the cacheKey hash are memoized - */ -function childContext(path, context) { - const { settings, parserOptions, parserPath } = context; - - if (JSON.stringify(settings) !== prevSettings) { - settingsHash = hashObject({ settings }).digest('hex'); - prevSettings = JSON.stringify(settings); - } - - if (JSON.stringify(parserOptions) !== prevParserOptions) { - parserOptionsHash = hashObject({ parserOptions }).digest('hex'); - prevParserOptions = JSON.stringify(parserOptions); - } - - return { - cacheKey: String(parserPath) + parserOptionsHash + settingsHash + String(path), - settings, - parserOptions, - parserPath, - path, - }; -} - -/** - * sometimes legacy support isn't _that_ hard... right? - */ -function makeSourceCode(text, ast) { - if (SourceCode.length > 1) { - // ESLint 3 - return new SourceCode(text, ast); - } else { - // ESLint 4, 5 - return new SourceCode({ text, ast }); - } -} diff --git a/src/core/fsWalk.js b/src/core/fsWalk.js new file mode 100644 index 0000000000..fa112590f1 --- /dev/null +++ b/src/core/fsWalk.js @@ -0,0 +1,48 @@ +/** + * This is intended to provide similar capability as the sync api from @nodelib/fs.walk, until `eslint-plugin-import` + * is willing to modernize and update their minimum node version to at least v16. I intentionally made the + * shape of the API (for the part we're using) the same as @nodelib/fs.walk so that that can be swapped in + * when the repo is ready for it. + */ + +import path from 'path'; +import { readdirSync } from 'fs'; + +/** @typedef {{ name: string, path: string, dirent: import('fs').Dirent }} Entry */ + +/** + * Do a comprehensive walk of the provided src directory, and collect all entries. Filter out + * any directories or entries using the optional filter functions. + * @param {string} root - path to the root of the folder we're walking + * @param {{ deepFilter?: (entry: Entry) => boolean, entryFilter?: (entry: Entry) => boolean }} options + * @param {Entry} currentEntry - entry for the current directory we're working in + * @param {Entry[]} existingEntries - list of all entries so far + * @returns {Entry[]} an array of directory entries + */ +export function walkSync(root, options, currentEntry, existingEntries) { + // Extract the filter functions. Default to evaluating true, if no filter passed in. + const { deepFilter = () => true, entryFilter = () => true } = options; + + let entryList = existingEntries || []; + const currentRelativePath = currentEntry ? currentEntry.path : '.'; + const fullPath = currentEntry ? path.join(root, currentEntry.path) : root; + + const dirents = readdirSync(fullPath, { withFileTypes: true }); + dirents.forEach((dirent) => { + /** @type {Entry} */ + const entry = { + name: dirent.name, + path: path.join(currentRelativePath, dirent.name), + dirent, + }; + + if (dirent.isDirectory() && deepFilter(entry)) { + entryList.push(entry); + entryList = walkSync(root, options, entry, entryList); + } else if (dirent.isFile() && entryFilter(entry)) { + entryList.push(entry); + } + }); + + return entryList; +} diff --git a/src/core/importType.js b/src/core/importType.js index 6a37d1bb14..32e200f1de 100644 --- a/src/core/importType.js +++ b/src/core/importType.js @@ -4,6 +4,11 @@ import isCoreModule from 'is-core-module'; import resolve from 'eslint-module-utils/resolve'; import { getContextPackagePath } from './packagePath'; +const scopedRegExp = /^@[^/]+\/?[^/]+/; +export function isScoped(name) { + return name && scopedRegExp.test(name); +} + function baseModule(name) { if (isScoped(name)) { const [scope, pkg] = name.split('/'); @@ -30,20 +35,6 @@ export function isBuiltIn(name, settings, path) { return isCoreModule(base) || extras.indexOf(base) > -1; } -export function isExternalModule(name, path, context) { - if (arguments.length < 3) { - throw new TypeError('isExternalModule: name, path, and context are all required'); - } - return (isModule(name) || isScoped(name)) && typeTest(name, context, path) === 'external'; -} - -export function isExternalModuleMain(name, path, context) { - if (arguments.length < 3) { - throw new TypeError('isExternalModule: name, path, and context are all required'); - } - return isModuleMain(name) && typeTest(name, context, path) === 'external'; -} - const moduleRegExp = /^\w/; function isModule(name) { return name && moduleRegExp.test(name); @@ -54,20 +45,9 @@ function isModuleMain(name) { return name && moduleMainRegExp.test(name); } -const scopedRegExp = /^@[^/]+\/?[^/]+/; -export function isScoped(name) { - return name && scopedRegExp.test(name); -} - -const scopedMainRegExp = /^@[^/]+\/?[^/]+$/; -export function isScopedMain(name) { - return name && scopedMainRegExp.test(name); -} - function isRelativeToParent(name) { return (/^\.\.$|^\.\.[\\/]/).test(name); } - const indexFiles = ['.', './', './index', './index.js']; function isIndex(name) { return indexFiles.indexOf(name) !== -1; @@ -123,6 +103,25 @@ function typeTest(name, context, path) { return 'unknown'; } +export function isExternalModule(name, path, context) { + if (arguments.length < 3) { + throw new TypeError('isExternalModule: name, path, and context are all required'); + } + return (isModule(name) || isScoped(name)) && typeTest(name, context, path) === 'external'; +} + +export function isExternalModuleMain(name, path, context) { + if (arguments.length < 3) { + throw new TypeError('isExternalModule: name, path, and context are all required'); + } + return isModuleMain(name) && typeTest(name, context, path) === 'external'; +} + +const scopedMainRegExp = /^@[^/]+\/?[^/]+$/; +export function isScopedMain(name) { + return name && scopedMainRegExp.test(name); +} + export default function resolveImportType(name, context) { return typeTest(name, context, resolve(name, context)); } diff --git a/src/core/packagePath.js b/src/core/packagePath.js index 1a7a28f4b4..142f44aa4d 100644 --- a/src/core/packagePath.js +++ b/src/core/packagePath.js @@ -2,15 +2,15 @@ import { dirname } from 'path'; import pkgUp from 'eslint-module-utils/pkgUp'; import readPkgUp from 'eslint-module-utils/readPkgUp'; -export function getContextPackagePath(context) { - return getFilePackagePath(context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename()); -} - export function getFilePackagePath(filePath) { const fp = pkgUp({ cwd: filePath }); return dirname(fp); } +export function getContextPackagePath(context) { + return getFilePackagePath(context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename()); +} + export function getFilePackageName(filePath) { const { pkg, path } = readPkgUp({ cwd: filePath, normalize: false }); if (pkg) { diff --git a/src/exportMap/builder.js b/src/exportMap/builder.js new file mode 100644 index 0000000000..5348dba375 --- /dev/null +++ b/src/exportMap/builder.js @@ -0,0 +1,206 @@ +import fs from 'fs'; + +import doctrine from 'doctrine'; + +import debug from 'debug'; + +import parse from 'eslint-module-utils/parse'; +import visit from 'eslint-module-utils/visit'; +import resolve from 'eslint-module-utils/resolve'; +import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'; + +import { hashObject } from 'eslint-module-utils/hash'; +import * as unambiguous from 'eslint-module-utils/unambiguous'; + +import ExportMap from '.'; +import childContext from './childContext'; +import { isEsModuleInterop } from './typescript'; +import { RemotePath } from './remotePath'; +import ImportExportVisitorBuilder from './visitor'; + +const log = debug('eslint-plugin-import:ExportMap'); + +const exportCache = new Map(); + +/** + * The creation of this closure is isolated from other scopes + * to avoid over-retention of unrelated variables, which has + * caused memory leaks. See #1266. + */ +function thunkFor(p, context) { + // eslint-disable-next-line no-use-before-define + return () => ExportMapBuilder.for(childContext(p, context)); +} + +export default class ExportMapBuilder { + static get(source, context) { + const path = resolve(source, context); + if (path == null) { return null; } + + return ExportMapBuilder.for(childContext(path, context)); + } + + static for(context) { + const { path } = context; + + const cacheKey = context.cacheKey || hashObject(context).digest('hex'); + let exportMap = exportCache.get(cacheKey); + + // return cached ignore + if (exportMap === null) { return null; } + + const stats = fs.statSync(path); + if (exportMap != null) { + // date equality check + if (exportMap.mtime - stats.mtime === 0) { + return exportMap; + } + // future: check content equality? + } + + // check valid extensions first + if (!hasValidExtension(path, context)) { + exportCache.set(cacheKey, null); + return null; + } + + // check for and cache ignore + if (isIgnored(path, context)) { + log('ignored path due to ignore settings:', path); + exportCache.set(cacheKey, null); + return null; + } + + const content = fs.readFileSync(path, { encoding: 'utf8' }); + + // check for and cache unambiguous modules + if (!unambiguous.test(content)) { + log('ignored path due to unambiguous regex:', path); + exportCache.set(cacheKey, null); + return null; + } + + log('cache miss', cacheKey, 'for path', path); + exportMap = ExportMapBuilder.parse(path, content, context); + + // ambiguous modules return null + if (exportMap == null) { + log('ignored path due to ambiguous parse:', path); + exportCache.set(cacheKey, null); + return null; + } + + exportMap.mtime = stats.mtime; + + exportCache.set(cacheKey, exportMap); + return exportMap; + } + + static parse(path, content, context) { + const exportMap = new ExportMap(path); + const isEsModuleInteropTrue = isEsModuleInterop(context); + + let ast; + let visitorKeys; + try { + const result = parse(path, content, context); + ast = result.ast; + visitorKeys = result.visitorKeys; + } catch (err) { + exportMap.errors.push(err); + return exportMap; // can't continue + } + + exportMap.visitorKeys = visitorKeys; + + let hasDynamicImports = false; + + const remotePathResolver = new RemotePath(path, context); + + function processDynamicImport(source) { + hasDynamicImports = true; + if (source.type !== 'Literal') { + return null; + } + const p = remotePathResolver.resolve(source.value); + if (p == null) { + return null; + } + const importedSpecifiers = new Set(); + importedSpecifiers.add('ImportNamespaceSpecifier'); + const getter = thunkFor(p, context); + exportMap.imports.set(p, { + getter, + declarations: new Set([{ + source: { + // capturing actual node reference holds full AST in memory! + value: source.value, + loc: source.loc, + }, + importedSpecifiers, + dynamic: true, + }]), + }); + } + + visit(ast, visitorKeys, { + ImportExpression(node) { + processDynamicImport(node.source); + }, + CallExpression(node) { + if (node.callee.type === 'Import') { + processDynamicImport(node.arguments[0]); + } + }, + }); + + const unambiguouslyESM = unambiguous.isModule(ast); + if (!unambiguouslyESM && !hasDynamicImports) { return null; } + + // attempt to collect module doc + if (ast.comments) { + ast.comments.some((c) => { + if (c.type !== 'Block') { return false; } + try { + const doc = doctrine.parse(c.value, { unwrap: true }); + if (doc.tags.some((t) => t.title === 'module')) { + exportMap.doc = doc; + return true; + } + } catch (err) { /* ignore */ } + return false; + }); + } + + const visitorBuilder = new ImportExportVisitorBuilder( + path, + context, + exportMap, + ExportMapBuilder, + content, + ast, + isEsModuleInteropTrue, + thunkFor, + ); + ast.body.forEach(function (astNode) { + const visitor = visitorBuilder.build(astNode); + + if (visitor[astNode.type]) { + visitor[astNode.type].call(visitorBuilder); + } + }); + + if ( + isEsModuleInteropTrue // esModuleInterop is on in tsconfig + && exportMap.namespace.size > 0 // anything is exported + && !exportMap.namespace.has('default') // and default isn't added already + ) { + exportMap.namespace.set('default', {}); // add default export + } + + if (unambiguouslyESM) { + exportMap.parseGoal = 'Module'; + } + return exportMap; + } +} diff --git a/src/exportMap/captureDependency.js b/src/exportMap/captureDependency.js new file mode 100644 index 0000000000..9ad37d0e20 --- /dev/null +++ b/src/exportMap/captureDependency.js @@ -0,0 +1,60 @@ +export function captureDependency( + { source }, + isOnlyImportingTypes, + remotePathResolver, + exportMap, + context, + thunkFor, + importedSpecifiers = new Set(), +) { + if (source == null) { return null; } + + const p = remotePathResolver.resolve(source.value); + if (p == null) { return null; } + + const declarationMetadata = { + // capturing actual node reference holds full AST in memory! + source: { value: source.value, loc: source.loc }, + isOnlyImportingTypes, + importedSpecifiers, + }; + + const existing = exportMap.imports.get(p); + if (existing != null) { + existing.declarations.add(declarationMetadata); + return existing.getter; + } + + const getter = thunkFor(p, context); + exportMap.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }); + return getter; +} + +const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']); + +export function captureDependencyWithSpecifiers( + n, + remotePathResolver, + exportMap, + context, + thunkFor, +) { + // import type { Foo } (TS and Flow); import typeof { Foo } (Flow) + const declarationIsType = n.importKind === 'type' || n.importKind === 'typeof'; + // import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and + // shouldn't be considered to be just importing types + let specifiersOnlyImportingTypes = n.specifiers.length > 0; + const importedSpecifiers = new Set(); + n.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportSpecifier') { + importedSpecifiers.add(specifier.imported.name || specifier.imported.value); + } else if (supportedImportTypes.has(specifier.type)) { + importedSpecifiers.add(specifier.type); + } + + // import { type Foo } (Flow); import { typeof Foo } (Flow) + specifiersOnlyImportingTypes = specifiersOnlyImportingTypes + && (specifier.importKind === 'type' || specifier.importKind === 'typeof'); + }); + captureDependency(n, declarationIsType || specifiersOnlyImportingTypes, remotePathResolver, exportMap, context, thunkFor, importedSpecifiers); +} diff --git a/src/exportMap/childContext.js b/src/exportMap/childContext.js new file mode 100644 index 0000000000..5f82b8e575 --- /dev/null +++ b/src/exportMap/childContext.js @@ -0,0 +1,32 @@ +import { hashObject } from 'eslint-module-utils/hash'; + +let parserOptionsHash = ''; +let prevParserOptions = ''; +let settingsHash = ''; +let prevSettings = ''; + +/** + * don't hold full context object in memory, just grab what we need. + * also calculate a cacheKey, where parts of the cacheKey hash are memoized + */ +export default function childContext(path, context) { + const { settings, parserOptions, parserPath } = context; + + if (JSON.stringify(settings) !== prevSettings) { + settingsHash = hashObject({ settings }).digest('hex'); + prevSettings = JSON.stringify(settings); + } + + if (JSON.stringify(parserOptions) !== prevParserOptions) { + parserOptionsHash = hashObject({ parserOptions }).digest('hex'); + prevParserOptions = JSON.stringify(parserOptions); + } + + return { + cacheKey: String(parserPath) + parserOptionsHash + settingsHash + String(path), + settings, + parserOptions, + parserPath, + path, + }; +} diff --git a/src/exportMap/doc.js b/src/exportMap/doc.js new file mode 100644 index 0000000000..c721ae25fc --- /dev/null +++ b/src/exportMap/doc.js @@ -0,0 +1,90 @@ +import doctrine from 'doctrine'; + +/** + * parse docs from the first node that has leading comments + */ +export function captureDoc(source, docStyleParsers, ...nodes) { + const metadata = {}; + + // 'some' short-circuits on first 'true' + nodes.some((n) => { + try { + + let leadingComments; + + // n.leadingComments is legacy `attachComments` behavior + if ('leadingComments' in n) { + leadingComments = n.leadingComments; + } else if (n.range) { + leadingComments = source.getCommentsBefore(n); + } + + if (!leadingComments || leadingComments.length === 0) { return false; } + + for (const name in docStyleParsers) { + const doc = docStyleParsers[name](leadingComments); + if (doc) { + metadata.doc = doc; + } + } + + return true; + } catch (err) { + return false; + } + }); + + return metadata; +} + +/** + * parse JSDoc from leading comments + * @param {object[]} comments + * @return {{ doc: object }} + */ +function captureJsDoc(comments) { + let doc; + + // capture XSDoc + comments.forEach((comment) => { + // skip non-block comments + if (comment.type !== 'Block') { return; } + try { + doc = doctrine.parse(comment.value, { unwrap: true }); + } catch (err) { + /* don't care, for now? maybe add to `errors?` */ + } + }); + + return doc; +} + +/** + * parse TomDoc section from comments + */ +function captureTomDoc(comments) { + // collect lines up to first paragraph break + const lines = []; + for (let i = 0; i < comments.length; i++) { + const comment = comments[i]; + if (comment.value.match(/^\s*$/)) { break; } + lines.push(comment.value.trim()); + } + + // return doctrine-like object + const statusMatch = lines.join(' ').match(/^(Public|Internal|Deprecated):\s*(.+)/); + if (statusMatch) { + return { + description: statusMatch[2], + tags: [{ + title: statusMatch[1].toLowerCase(), + description: statusMatch[2], + }], + }; + } +} + +export const availableDocStyleParsers = { + jsdoc: captureJsDoc, + tomdoc: captureTomDoc, +}; diff --git a/src/exportMap/index.js b/src/exportMap/index.js new file mode 100644 index 0000000000..e4d61638c5 --- /dev/null +++ b/src/exportMap/index.js @@ -0,0 +1,178 @@ +export default class ExportMap { + constructor(path) { + this.path = path; + this.namespace = new Map(); + // todo: restructure to key on path, value is resolver + map of names + this.reexports = new Map(); + /** + * star-exports + * @type {Set<() => ExportMap>} + */ + this.dependencies = new Set(); + /** + * dependencies of this module that are not explicitly re-exported + * @type {Map ExportMap>} + */ + this.imports = new Map(); + this.errors = []; + /** + * type {'ambiguous' | 'Module' | 'Script'} + */ + this.parseGoal = 'ambiguous'; + } + + get hasDefault() { return this.get('default') != null; } // stronger than this.has + + get size() { + let size = this.namespace.size + this.reexports.size; + this.dependencies.forEach((dep) => { + const d = dep(); + // CJS / ignored dependencies won't exist (#717) + if (d == null) { return; } + size += d.size; + }); + return size; + } + + /** + * Note that this does not check explicitly re-exported names for existence + * in the base namespace, but it will expand all `export * from '...'` exports + * if not found in the explicit namespace. + * @param {string} name + * @return {boolean} true if `name` is exported by this module. + */ + has(name) { + if (this.namespace.has(name)) { return true; } + if (this.reexports.has(name)) { return true; } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep(); + + // todo: report as unresolved? + if (!innerMap) { continue; } + + if (innerMap.has(name)) { return true; } + } + } + + return false; + } + + /** + * ensure that imported name fully resolves. + * @param {string} name + * @return {{ found: boolean, path: ExportMap[] }} + */ + hasDeep(name) { + if (this.namespace.has(name)) { return { found: true, path: [this] }; } + + if (this.reexports.has(name)) { + const reexports = this.reexports.get(name); + const imported = reexports.getImport(); + + // if import is ignored, return explicit 'null' + if (imported == null) { return { found: true, path: [this] }; } + + // safeguard against cycles, only if name matches + if (imported.path === this.path && reexports.local === name) { + return { found: false, path: [this] }; + } + + const deep = imported.hasDeep(reexports.local); + deep.path.unshift(this); + + return deep; + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep(); + if (innerMap == null) { return { found: true, path: [this] }; } + // todo: report as unresolved? + if (!innerMap) { continue; } + + // safeguard against cycles + if (innerMap.path === this.path) { continue; } + + const innerValue = innerMap.hasDeep(name); + if (innerValue.found) { + innerValue.path.unshift(this); + return innerValue; + } + } + } + + return { found: false, path: [this] }; + } + + get(name) { + if (this.namespace.has(name)) { return this.namespace.get(name); } + + if (this.reexports.has(name)) { + const reexports = this.reexports.get(name); + const imported = reexports.getImport(); + + // if import is ignored, return explicit 'null' + if (imported == null) { return null; } + + // safeguard against cycles, only if name matches + if (imported.path === this.path && reexports.local === name) { return undefined; } + + return imported.get(reexports.local); + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep(); + // todo: report as unresolved? + if (!innerMap) { continue; } + + // safeguard against cycles + if (innerMap.path === this.path) { continue; } + + const innerValue = innerMap.get(name); + if (innerValue !== undefined) { return innerValue; } + } + } + + return undefined; + } + + forEach(callback, thisArg) { + this.namespace.forEach((v, n) => { callback.call(thisArg, v, n, this); }); + + this.reexports.forEach((reexports, name) => { + const reexported = reexports.getImport(); + // can't look up meta for ignored re-exports (#348) + callback.call(thisArg, reexported && reexported.get(reexports.local), name, this); + }); + + this.dependencies.forEach((dep) => { + const d = dep(); + // CJS / ignored dependencies won't exist (#717) + if (d == null) { return; } + + d.forEach((v, n) => { + if (n !== 'default') { + callback.call(thisArg, v, n, this); + } + }); + }); + } + + // todo: keys, values, entries? + + reportErrors(context, declaration) { + const msg = this.errors + .map((e) => `${e.message} (${e.lineNumber}:${e.column})`) + .join(', '); + context.report({ + node: declaration.source, + message: `Parse errors in imported module '${declaration.source.value}': ${msg}`, + }); + } +} diff --git a/src/exportMap/namespace.js b/src/exportMap/namespace.js new file mode 100644 index 0000000000..370f47579d --- /dev/null +++ b/src/exportMap/namespace.js @@ -0,0 +1,39 @@ +import childContext from './childContext'; +import { RemotePath } from './remotePath'; + +export default class Namespace { + constructor( + path, + context, + ExportMapBuilder, + ) { + this.remotePathResolver = new RemotePath(path, context); + this.context = context; + this.ExportMapBuilder = ExportMapBuilder; + this.namespaces = new Map(); + } + + resolveImport(value) { + const rp = this.remotePathResolver.resolve(value); + if (rp == null) { return null; } + return this.ExportMapBuilder.for(childContext(rp, this.context)); + } + + getNamespace(identifier) { + if (!this.namespaces.has(identifier.name)) { return; } + return () => this.resolveImport(this.namespaces.get(identifier.name)); + } + + add(object, identifier) { + const nsfn = this.getNamespace(identifier); + if (nsfn) { + Object.defineProperty(object, 'namespace', { get: nsfn }); + } + + return object; + } + + rawSet(name, value) { + this.namespaces.set(name, value); + } +} diff --git a/src/exportMap/patternCapture.js b/src/exportMap/patternCapture.js new file mode 100644 index 0000000000..5bc9806417 --- /dev/null +++ b/src/exportMap/patternCapture.js @@ -0,0 +1,40 @@ +/** + * Traverse a pattern/identifier node, calling 'callback' + * for each leaf identifier. + * @param {node} pattern + * @param {Function} callback + * @return {void} + */ +export default function recursivePatternCapture(pattern, callback) { + switch (pattern.type) { + case 'Identifier': // base case + callback(pattern); + break; + + case 'ObjectPattern': + pattern.properties.forEach((p) => { + if (p.type === 'ExperimentalRestProperty' || p.type === 'RestElement') { + callback(p.argument); + return; + } + recursivePatternCapture(p.value, callback); + }); + break; + + case 'ArrayPattern': + pattern.elements.forEach((element) => { + if (element == null) { return; } + if (element.type === 'ExperimentalRestProperty' || element.type === 'RestElement') { + callback(element.argument); + return; + } + recursivePatternCapture(element, callback); + }); + break; + + case 'AssignmentPattern': + callback(pattern.left); + break; + default: + } +} diff --git a/src/exportMap/remotePath.js b/src/exportMap/remotePath.js new file mode 100644 index 0000000000..0dc5fc0954 --- /dev/null +++ b/src/exportMap/remotePath.js @@ -0,0 +1,12 @@ +import resolve from 'eslint-module-utils/resolve'; + +export class RemotePath { + constructor(path, context) { + this.path = path; + this.context = context; + } + + resolve(value) { + return resolve.relative(value, this.path, this.context.settings); + } +} diff --git a/src/exportMap/specifier.js b/src/exportMap/specifier.js new file mode 100644 index 0000000000..dfaaf618e4 --- /dev/null +++ b/src/exportMap/specifier.js @@ -0,0 +1,32 @@ +export default function processSpecifier(specifier, astNode, exportMap, namespace) { + const nsource = astNode.source && astNode.source.value; + const exportMeta = {}; + let local; + + switch (specifier.type) { + case 'ExportDefaultSpecifier': + if (!nsource) { return; } + local = 'default'; + break; + case 'ExportNamespaceSpecifier': + exportMap.namespace.set(specifier.exported.name, Object.defineProperty(exportMeta, 'namespace', { + get() { return namespace.resolveImport(nsource); }, + })); + return; + case 'ExportAllDeclaration': + exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.source.value)); + return; + case 'ExportSpecifier': + if (!astNode.source) { + exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.local)); + return; + } + // else falls through + default: + local = specifier.local.name; + break; + } + + // todo: JSDoc + exportMap.reexports.set(specifier.exported.name, { local, getImport: () => namespace.resolveImport(nsource) }); +} diff --git a/src/exportMap/typescript.js b/src/exportMap/typescript.js new file mode 100644 index 0000000000..7db4356da8 --- /dev/null +++ b/src/exportMap/typescript.js @@ -0,0 +1,43 @@ +import { dirname } from 'path'; +import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'; +import { hashObject } from 'eslint-module-utils/hash'; + +let ts; +const tsconfigCache = new Map(); + +function readTsConfig(context) { + const tsconfigInfo = tsConfigLoader({ + cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), + getEnv: (key) => process.env[key], + }); + try { + if (tsconfigInfo.tsConfigPath !== undefined) { + // Projects not using TypeScript won't have `typescript` installed. + if (!ts) { ts = require('typescript'); } // eslint-disable-line import/no-extraneous-dependencies + + const configFile = ts.readConfigFile(tsconfigInfo.tsConfigPath, ts.sys.readFile); + return ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + dirname(tsconfigInfo.tsConfigPath), + ); + } + } catch (e) { + // Catch any errors + } + + return null; +} + +export function isEsModuleInterop(context) { + const cacheKey = hashObject({ + tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, + }).digest('hex'); + let tsConfig = tsconfigCache.get(cacheKey); + if (typeof tsConfig === 'undefined') { + tsConfig = readTsConfig(context); + tsconfigCache.set(cacheKey, tsConfig); + } + + return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false; +} diff --git a/src/exportMap/visitor.js b/src/exportMap/visitor.js new file mode 100644 index 0000000000..21c1a7c644 --- /dev/null +++ b/src/exportMap/visitor.js @@ -0,0 +1,171 @@ +import includes from 'array-includes'; +import { SourceCode } from 'eslint'; +import { availableDocStyleParsers, captureDoc } from './doc'; +import Namespace from './namespace'; +import processSpecifier from './specifier'; +import { captureDependency, captureDependencyWithSpecifiers } from './captureDependency'; +import recursivePatternCapture from './patternCapture'; +import { RemotePath } from './remotePath'; + +/** + * sometimes legacy support isn't _that_ hard... right? + */ +function makeSourceCode(text, ast) { + if (SourceCode.length > 1) { + // ESLint 3 + return new SourceCode(text, ast); + } else { + // ESLint 4, 5 + return new SourceCode({ text, ast }); + } +} + +export default class ImportExportVisitorBuilder { + constructor( + path, + context, + exportMap, + ExportMapBuilder, + content, + ast, + isEsModuleInteropTrue, + thunkFor, + ) { + this.context = context; + this.namespace = new Namespace(path, context, ExportMapBuilder); + this.remotePathResolver = new RemotePath(path, context); + this.source = makeSourceCode(content, ast); + this.exportMap = exportMap; + this.ast = ast; + this.isEsModuleInteropTrue = isEsModuleInteropTrue; + this.thunkFor = thunkFor; + const docstyle = this.context.settings && this.context.settings['import/docstyle'] || ['jsdoc']; + this.docStyleParsers = {}; + docstyle.forEach((style) => { + this.docStyleParsers[style] = availableDocStyleParsers[style]; + }); + } + + build(astNode) { + return { + ExportDefaultDeclaration() { + const exportMeta = captureDoc(this.source, this.docStyleParsers, astNode); + if (astNode.declaration.type === 'Identifier') { + this.namespace.add(exportMeta, astNode.declaration); + } + this.exportMap.namespace.set('default', exportMeta); + }, + ExportAllDeclaration() { + const getter = captureDependency(astNode, astNode.exportKind === 'type', this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + if (getter) { this.exportMap.dependencies.add(getter); } + if (astNode.exported) { + processSpecifier(astNode, astNode.exported, this.exportMap, this.namespace); + } + }, + /** capture namespaces in case of later export */ + ImportDeclaration() { + captureDependencyWithSpecifiers(astNode, this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + const ns = astNode.specifiers.find((s) => s.type === 'ImportNamespaceSpecifier'); + if (ns) { + this.namespace.rawSet(ns.local.name, astNode.source.value); + } + }, + ExportNamedDeclaration() { + captureDependencyWithSpecifiers(astNode, this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + // capture declaration + if (astNode.declaration != null) { + switch (astNode.declaration.type) { + case 'FunctionDeclaration': + case 'ClassDeclaration': + case 'TypeAlias': // flowtype with babel-eslint parser + case 'InterfaceDeclaration': + case 'DeclareFunction': + case 'TSDeclareFunction': + case 'TSEnumDeclaration': + case 'TSTypeAliasDeclaration': + case 'TSInterfaceDeclaration': + case 'TSAbstractClassDeclaration': + case 'TSModuleDeclaration': + this.exportMap.namespace.set(astNode.declaration.id.name, captureDoc(this.source, this.docStyleParsers, astNode)); + break; + case 'VariableDeclaration': + astNode.declaration.declarations.forEach((d) => { + recursivePatternCapture( + d.id, + (id) => this.exportMap.namespace.set(id.name, captureDoc(this.source, this.docStyleParsers, d, astNode)), + ); + }); + break; + default: + } + } + astNode.specifiers.forEach((s) => processSpecifier(s, astNode, this.exportMap, this.namespace)); + }, + TSExportAssignment: () => this.typeScriptExport(astNode), + ...this.isEsModuleInteropTrue && { TSNamespaceExportDeclaration: () => this.typeScriptExport(astNode) }, + }; + } + + // This doesn't declare anything, but changes what's being exported. + typeScriptExport(astNode) { + const exportedName = astNode.type === 'TSNamespaceExportDeclaration' + ? (astNode.id || astNode.name).name + : astNode.expression && astNode.expression.name || astNode.expression.id && astNode.expression.id.name || null; + const declTypes = [ + 'VariableDeclaration', + 'ClassDeclaration', + 'TSDeclareFunction', + 'TSEnumDeclaration', + 'TSTypeAliasDeclaration', + 'TSInterfaceDeclaration', + 'TSAbstractClassDeclaration', + 'TSModuleDeclaration', + ]; + const exportedDecls = this.ast.body.filter(({ type, id, declarations }) => includes(declTypes, type) && ( + id && id.name === exportedName || declarations && declarations.find((d) => d.id.name === exportedName) + )); + if (exportedDecls.length === 0) { + // Export is not referencing any local declaration, must be re-exporting + this.exportMap.namespace.set('default', captureDoc(this.source, this.docStyleParsers, astNode)); + return; + } + if ( + this.isEsModuleInteropTrue // esModuleInterop is on in tsconfig + && !this.exportMap.namespace.has('default') // and default isn't added already + ) { + this.exportMap.namespace.set('default', {}); // add default export + } + exportedDecls.forEach((decl) => { + if (decl.type === 'TSModuleDeclaration') { + if (decl.body && decl.body.type === 'TSModuleDeclaration') { + this.exportMap.namespace.set(decl.body.id.name, captureDoc(this.source, this.docStyleParsers, decl.body)); + } else if (decl.body && decl.body.body) { + decl.body.body.forEach((moduleBlockNode) => { + // Export-assignment exports all members in the namespace, + // explicitly exported or not. + const namespaceDecl = moduleBlockNode.type === 'ExportNamedDeclaration' + ? moduleBlockNode.declaration + : moduleBlockNode; + + if (!namespaceDecl) { + // TypeScript can check this for us; we needn't + } else if (namespaceDecl.type === 'VariableDeclaration') { + namespaceDecl.declarations.forEach((d) => recursivePatternCapture(d.id, (id) => this.exportMap.namespace.set( + id.name, + captureDoc(this.source, this.docStyleParsers, decl, namespaceDecl, moduleBlockNode), + )), + ); + } else { + this.exportMap.namespace.set( + namespaceDecl.id.name, + captureDoc(this.source, this.docStyleParsers, moduleBlockNode)); + } + }); + } + } else { + // Export as default + this.exportMap.namespace.set('default', captureDoc(this.source, this.docStyleParsers, decl)); + } + }); + } +} diff --git a/src/index.js b/src/index.js index feafba9003..0ab82ebee8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,5 @@ +import { name, version } from '../package.json'; + export const rules = { 'no-unresolved': require('./rules/no-unresolved'), named: require('./rules/named'), @@ -69,3 +71,32 @@ export const configs = { electron: require('../config/electron'), typescript: require('../config/typescript'), }; + +// Base Plugin Object +const importPlugin = { + meta: { name, version }, + rules, +}; + +// Create flat configs (Only ones that declare plugins and parser options need to be different from the legacy config) +const createFlatConfig = (baseConfig, configName) => ({ + ...baseConfig, + name: `import/${configName}`, + plugins: { import: importPlugin }, +}); + +export const flatConfigs = { + recommended: createFlatConfig( + require('../config/flat/recommended'), + 'recommended', + ), + + errors: createFlatConfig(require('../config/flat/errors'), 'errors'), + warnings: createFlatConfig(require('../config/flat/warnings'), 'warnings'), + + // useful stuff for folks using various environments + react: require('../config/flat/react'), + 'react-native': configs['react-native'], + electron: configs.electron, + typescript: configs.typescript, +}; diff --git a/src/rules/default.js b/src/rules/default.js index 297a80c463..0de787c33c 100644 --- a/src/rules/default.js +++ b/src/rules/default.js @@ -1,4 +1,4 @@ -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import docsUrl from '../docsUrl'; module.exports = { @@ -19,7 +19,7 @@ module.exports = { ); if (!defaultSpecifier) { return; } - const imports = Exports.get(node.source.value, context); + const imports = ExportMapBuilder.get(node.source.value, context); if (imports == null) { return; } if (imports.errors.length) { diff --git a/src/rules/dynamic-import-chunkname.js b/src/rules/dynamic-import-chunkname.js index 96ceff2e16..a72b04d123 100644 --- a/src/rules/dynamic-import-chunkname.js +++ b/src/rules/dynamic-import-chunkname.js @@ -19,22 +19,28 @@ module.exports = { type: 'string', }, }, + allowEmpty: { + type: 'boolean', + }, webpackChunknameFormat: { type: 'string', }, }, }], + hasSuggestions: true, }, create(context) { const config = context.options[0]; - const { importFunctions = [] } = config || {}; + const { importFunctions = [], allowEmpty = false } = config || {}; const { webpackChunknameFormat = '([0-9a-zA-Z-_/.]|\\[(request|index)\\])+' } = config || {}; const paddedCommentRegex = /^ (\S[\s\S]+\S) $/; const commentStyleRegex = /^( ((webpackChunkName: .+)|((webpackPrefetch|webpackPreload): (true|false|-?[0-9]+))|(webpackIgnore: (true|false))|((webpackInclude|webpackExclude): \/.*\/)|(webpackMode: ["'](lazy|lazy-once|eager|weak)["'])|(webpackExports: (['"]\w+['"]|\[(['"]\w+['"], *)+(['"]\w+['"]*)\]))),?)+ $/; - const chunkSubstrFormat = ` webpackChunkName: ["']${webpackChunknameFormat}["'],? `; + const chunkSubstrFormat = `webpackChunkName: ["']${webpackChunknameFormat}["'],? `; const chunkSubstrRegex = new RegExp(chunkSubstrFormat); + const eagerModeFormat = `webpackMode: ["']eager["'],? `; + const eagerModeRegex = new RegExp(eagerModeFormat); function run(node, arg) { const sourceCode = context.getSourceCode(); @@ -42,7 +48,7 @@ module.exports = { ? sourceCode.getCommentsBefore(arg) // This method is available in ESLint >= 4. : sourceCode.getComments(arg).leading; // This method is deprecated in ESLint 7. - if (!leadingComments || leadingComments.length === 0) { + if ((!leadingComments || leadingComments.length === 0) && !allowEmpty) { context.report({ node, message: 'dynamic imports require a leading comment with the webpack chunkname', @@ -51,6 +57,7 @@ module.exports = { } let isChunknamePresent = false; + let isEagerModePresent = false; for (const comment of leadingComments) { if (comment.type !== 'Block') { @@ -89,12 +96,55 @@ module.exports = { return; } + if (eagerModeRegex.test(comment.value)) { + isEagerModePresent = true; + } + if (chunkSubstrRegex.test(comment.value)) { isChunknamePresent = true; } } - if (!isChunknamePresent) { + if (isChunknamePresent && isEagerModePresent) { + context.report({ + node, + message: 'dynamic imports using eager mode do not need a webpackChunkName', + suggest: [ + { + desc: 'Remove webpackChunkName', + fix(fixer) { + for (const comment of leadingComments) { + if (chunkSubstrRegex.test(comment.value)) { + const replacement = comment.value.replace(chunkSubstrRegex, '').trim().replace(/,$/, ''); + if (replacement === '') { + return fixer.remove(comment); + } else { + return fixer.replaceText(comment, `/* ${replacement} */`); + } + } + } + }, + }, + { + desc: 'Remove webpackMode', + fix(fixer) { + for (const comment of leadingComments) { + if (eagerModeRegex.test(comment.value)) { + const replacement = comment.value.replace(eagerModeRegex, '').trim().replace(/,$/, ''); + if (replacement === '') { + return fixer.remove(comment); + } else { + return fixer.replaceText(comment, `/* ${replacement} */`); + } + } + } + }, + }, + ], + }); + } + + if (!isChunknamePresent && !allowEmpty && !isEagerModePresent) { context.report({ node, message: diff --git a/src/rules/export.js b/src/rules/export.js index c540f1e3c9..197a0eb51c 100644 --- a/src/rules/export.js +++ b/src/rules/export.js @@ -1,4 +1,5 @@ -import ExportMap, { recursivePatternCapture } from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import recursivePatternCapture from '../exportMap/patternCapture'; import docsUrl from '../docsUrl'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; @@ -197,7 +198,7 @@ module.exports = { // `export * as X from 'path'` does not conflict if (node.exported && node.exported.name) { return; } - const remoteExports = ExportMap.get(node.source.value, context); + const remoteExports = ExportMapBuilder.get(node.source.value, context); if (remoteExports == null) { return; } if (remoteExports.errors.length) { diff --git a/src/rules/named.js b/src/rules/named.js index e7fe4e4dce..ed7e5e018b 100644 --- a/src/rules/named.js +++ b/src/rules/named.js @@ -1,5 +1,5 @@ import * as path from 'path'; -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import docsUrl from '../docsUrl'; module.exports = { @@ -41,7 +41,7 @@ module.exports = { return; // no named imports/exports } - const imports = Exports.get(node.source.value, context); + const imports = ExportMapBuilder.get(node.source.value, context); if (imports == null || imports.parseGoal === 'ambiguous') { return; } @@ -93,7 +93,7 @@ module.exports = { const call = node.init; const [source] = call.arguments; const variableImports = node.id.properties; - const variableExports = Exports.get(source.value, context); + const variableExports = ExportMapBuilder.get(source.value, context); if ( // return if it's not a commonjs require statement diff --git a/src/rules/namespace.js b/src/rules/namespace.js index 77a3ea9077..60a4220de2 100644 --- a/src/rules/namespace.js +++ b/src/rules/namespace.js @@ -1,5 +1,6 @@ import declaredScope from 'eslint-module-utils/declaredScope'; -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import ExportMap from '../exportMap'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; @@ -8,7 +9,7 @@ function processBodyStatement(context, namespaces, declaration) { if (declaration.specifiers.length === 0) { return; } - const imports = Exports.get(declaration.source.value, context); + const imports = ExportMapBuilder.get(declaration.source.value, context); if (imports == null) { return null; } if (imports.errors.length > 0) { @@ -88,7 +89,7 @@ module.exports = { ExportNamespaceSpecifier(namespace) { const declaration = importDeclaration(context); - const imports = Exports.get(declaration.source.value, context); + const imports = ExportMapBuilder.get(declaration.source.value, context); if (imports == null) { return null; } if (imports.errors.length) { @@ -122,7 +123,7 @@ module.exports = { let namespace = namespaces.get(dereference.object.name); const namepath = [dereference.object.name]; // while property is namespace and parent is member expression, keep validating - while (namespace instanceof Exports && dereference.type === 'MemberExpression') { + while (namespace instanceof ExportMap && dereference.type === 'MemberExpression') { if (dereference.computed) { if (!allowComputed) { context.report( @@ -161,7 +162,7 @@ module.exports = { // DFS traverse child namespaces function testKey(pattern, namespace, path = [init.name]) { - if (!(namespace instanceof Exports)) { return; } + if (!(namespace instanceof ExportMap)) { return; } if (pattern.type !== 'ObjectPattern') { return; } diff --git a/src/rules/newline-after-import.js b/src/rules/newline-after-import.js index a33bb615b9..d10b87d78c 100644 --- a/src/rules/newline-after-import.js +++ b/src/rules/newline-after-import.js @@ -124,7 +124,7 @@ module.exports = { } } - function commentAfterImport(node, nextComment) { + function commentAfterImport(node, nextComment, type) { const lineDifference = getLineDifference(node, nextComment); const EXPECTED_LINE_DIFFERENCE = options.count + 1; @@ -140,7 +140,7 @@ module.exports = { line: node.loc.end.line, column, }, - message: `Expected ${options.count} empty line${options.count > 1 ? 's' : ''} after import statement not followed by another import.`, + message: `Expected ${options.count} empty line${options.count > 1 ? 's' : ''} after ${type} statement not followed by another ${type}.`, fix: options.exactCount && EXPECTED_LINE_DIFFERENCE < lineDifference ? undefined : (fixer) => fixer.insertTextAfter( node, '\n'.repeat(EXPECTED_LINE_DIFFERENCE - lineDifference), @@ -178,7 +178,7 @@ module.exports = { } if (nextComment && typeof nextComment !== 'undefined') { - commentAfterImport(node, nextComment); + commentAfterImport(node, nextComment, 'import'); } else if (nextNode && nextNode.type !== 'ImportDeclaration' && (nextNode.type !== 'TSImportEqualsDeclaration' || nextNode.isExport)) { checkForNewLine(node, nextNode, 'import'); } @@ -215,8 +215,18 @@ module.exports = { || !containsNodeOrEqual(nextStatement, nextRequireCall) ) ) { - - checkForNewLine(statementWithRequireCall, nextStatement, 'require'); + let nextComment; + if (typeof statementWithRequireCall.parent.comments !== 'undefined' && options.considerComments) { + const endLine = node.loc.end.line; + nextComment = statementWithRequireCall.parent.comments.find((o) => o.loc.start.line >= endLine && o.loc.start.line <= endLine + options.count + 1); + } + + if (nextComment && typeof nextComment !== 'undefined') { + + commentAfterImport(statementWithRequireCall, nextComment, 'require'); + } else { + checkForNewLine(statementWithRequireCall, nextStatement, 'require'); + } } }); }, diff --git a/src/rules/no-cycle.js b/src/rules/no-cycle.js index 5b9d8c0709..be8c288dd4 100644 --- a/src/rules/no-cycle.js +++ b/src/rules/no-cycle.js @@ -4,13 +4,18 @@ */ import resolve from 'eslint-module-utils/resolve'; -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import StronglyConnectedComponentsBuilder from '../scc'; import { isExternalModule } from '../core/importType'; import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; import docsUrl from '../docsUrl'; const traversed = new Set(); +function routeString(route) { + return route.map((s) => `${s.value}:${s.loc.start.line}`).join('=>'); +} + module.exports = { meta: { type: 'suggestion', @@ -43,6 +48,11 @@ module.exports = { type: 'boolean', default: false, }, + disableScc: { + description: 'When true, don\'t calculate a strongly-connected-components graph. SCC is used to reduce the time-complexity of cycle detection, but adds overhead.', + type: 'boolean', + default: false, + }, })], }, @@ -58,6 +68,8 @@ module.exports = { context, ); + const scc = options.disableScc ? {} : StronglyConnectedComponentsBuilder.get(myPath, context); + function checkSourceValue(sourceNode, importer) { if (ignoreModule(sourceNode.value)) { return; // ignore external modules @@ -84,7 +96,7 @@ module.exports = { return; // ignore type imports } - const imported = Exports.get(sourceNode.value, context); + const imported = ExportMapBuilder.get(sourceNode.value, context); if (imported == null) { return; // no-unresolved territory @@ -94,6 +106,16 @@ module.exports = { return; // no-self-import territory } + /* If we're in the same Strongly Connected Component, + * Then there exists a path from each node in the SCC to every other node in the SCC, + * Then there exists at least one path from them to us and from us to them, + * Then we have a cycle between us. + */ + const hasDependencyCycle = options.disableScc || scc[myPath] === scc[imported.path]; + if (!hasDependencyCycle) { + return; + } + const untraversed = [{ mget: () => imported, route: [] }]; function detectCycle({ mget, route }) { const m = mget(); @@ -102,6 +124,9 @@ module.exports = { traversed.add(m.path); for (const [path, { getter, declarations }] of m.imports) { + // If we're in different SCCs, we can't have a circular dependency + if (!options.disableScc && scc[myPath] !== scc[path]) { continue; } + if (traversed.has(path)) { continue; } const toTraverse = [...declarations].filter(({ source, isOnlyImportingTypes }) => !ignoreModule(source.value) // Ignore only type imports @@ -151,7 +176,3 @@ module.exports = { }); }, }; - -function routeString(route) { - return route.map((s) => `${s.value}:${s.loc.start.line}`).join('=>'); -} diff --git a/src/rules/no-deprecated.js b/src/rules/no-deprecated.js index 06eeff8ea7..b4299a51d4 100644 --- a/src/rules/no-deprecated.js +++ b/src/rules/no-deprecated.js @@ -1,5 +1,6 @@ import declaredScope from 'eslint-module-utils/declaredScope'; -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; +import ExportMap from '../exportMap'; import docsUrl from '../docsUrl'; function message(deprecation) { @@ -31,7 +32,7 @@ module.exports = { if (node.type !== 'ImportDeclaration') { return; } if (node.source == null) { return; } // local export, ignore - const imports = Exports.get(node.source.value, context); + const imports = ExportMapBuilder.get(node.source.value, context); if (imports == null) { return; } const moduleDeprecation = imports.doc && imports.doc.tags.find((t) => t.title === 'deprecated'); @@ -114,7 +115,7 @@ module.exports = { let namespace = namespaces.get(dereference.object.name); const namepath = [dereference.object.name]; // while property is namespace and parent is member expression, keep validating - while (namespace instanceof Exports && dereference.type === 'MemberExpression') { + while (namespace instanceof ExportMap && dereference.type === 'MemberExpression') { // ignore computed parts for now if (dereference.computed) { return; } diff --git a/src/rules/no-duplicates.js b/src/rules/no-duplicates.js index 6b4f4d559e..d9fb1a1309 100644 --- a/src/rules/no-duplicates.js +++ b/src/rules/no-duplicates.js @@ -9,28 +9,68 @@ try { typescriptPkg = require('typescript/package.json'); // eslint-disable-line import/no-extraneous-dependencies } catch (e) { /**/ } -function checkImports(imported, context) { - for (const [module, nodes] of imported.entries()) { - if (nodes.length > 1) { - const message = `'${module}' imported multiple times.`; - const [first, ...rest] = nodes; - const sourceCode = context.getSourceCode(); - const fix = getFix(first, rest, sourceCode, context); +function isPunctuator(node, value) { + return node.type === 'Punctuator' && node.value === value; +} - context.report({ - node: first.source, - message, - fix, // Attach the autofix (if any) to the first import. - }); +// Get the name of the default import of `node`, if any. +function getDefaultImportName(node) { + const defaultSpecifier = node.specifiers + .find((specifier) => specifier.type === 'ImportDefaultSpecifier'); + return defaultSpecifier != null ? defaultSpecifier.local.name : undefined; +} - for (const node of rest) { - context.report({ - node: node.source, - message, - }); - } - } - } +// Checks whether `node` has a namespace import. +function hasNamespace(node) { + const specifiers = node.specifiers + .filter((specifier) => specifier.type === 'ImportNamespaceSpecifier'); + return specifiers.length > 0; +} + +// Checks whether `node` has any non-default specifiers. +function hasSpecifiers(node) { + const specifiers = node.specifiers + .filter((specifier) => specifier.type === 'ImportSpecifier'); + return specifiers.length > 0; +} + +// Checks whether `node` has a comment (that ends) on the previous line or on +// the same line as `node` (starts). +function hasCommentBefore(node, sourceCode) { + return sourceCode.getCommentsBefore(node) + .some((comment) => comment.loc.end.line >= node.loc.start.line - 1); +} + +// Checks whether `node` has a comment (that starts) on the same line as `node` +// (ends). +function hasCommentAfter(node, sourceCode) { + return sourceCode.getCommentsAfter(node) + .some((comment) => comment.loc.start.line === node.loc.end.line); +} + +// Checks whether `node` has any comments _inside,_ except inside the `{...}` +// part (if any). +function hasCommentInsideNonSpecifiers(node, sourceCode) { + const tokens = sourceCode.getTokens(node); + const openBraceIndex = tokens.findIndex((token) => isPunctuator(token, '{')); + const closeBraceIndex = tokens.findIndex((token) => isPunctuator(token, '}')); + // Slice away the first token, since we're no looking for comments _before_ + // `node` (only inside). If there's a `{...}` part, look for comments before + // the `{`, but not before the `}` (hence the `+1`s). + const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0 + ? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1)) + : tokens.slice(1); + return someTokens.some((token) => sourceCode.getCommentsBefore(token).length > 0); +} + +// It's not obvious what the user wants to do with comments associated with +// duplicate imports, so skip imports with comments when autofixing. +function hasProblematicComments(node, sourceCode) { + return ( + hasCommentBefore(node, sourceCode) + || hasCommentAfter(node, sourceCode) + || hasCommentInsideNonSpecifiers(node, sourceCode) + ); } function getFix(first, rest, sourceCode, context) { @@ -92,6 +132,7 @@ function getFix(first, rest, sourceCode, context) { const shouldAddDefault = getDefaultImportName(first) == null && defaultImportNames.size === 1; const shouldAddSpecifiers = specifiers.length > 0; const shouldRemoveUnnecessary = unnecessaryImports.length > 0; + const preferInline = context.options[0] && context.options[0]['prefer-inline']; if (!(shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary)) { return undefined; @@ -117,8 +158,7 @@ function getFix(first, rest, sourceCode, context) { ([result, needsComma, existingIdentifiers], specifier) => { const isTypeSpecifier = specifier.importNode.importKind === 'type'; - const preferInline = context.options[0] && context.options[0]['prefer-inline']; - // a user might set prefer-inline but not have a supporting TypeScript version. Flow does not support inline types so this should fail in that case as well. + // a user might set prefer-inline but not have a supporting TypeScript version. Flow does not support inline types so this should fail in that case as well. if (preferInline && (!typescriptPkg || !semver.satisfies(typescriptPkg.version, '>= 4.5'))) { throw new Error('Your version of TypeScript does not support inline type imports.'); } @@ -146,6 +186,18 @@ function getFix(first, rest, sourceCode, context) { const fixes = []; + if (shouldAddSpecifiers && preferInline && first.importKind === 'type') { + // `import type {a} from './foo'` → `import {type a} from './foo'` + const typeIdentifierToken = tokens.find((token) => token.type === 'Identifier' && token.value === 'type'); + fixes.push(fixer.removeRange([typeIdentifierToken.range[0], typeIdentifierToken.range[1] + 1])); + + tokens + .filter((token) => firstExistingIdentifiers.has(token.value)) + .forEach((identifier) => { + fixes.push(fixer.replaceTextRange([identifier.range[0], identifier.range[1]], `type ${identifier.value}`)); + }); + } + if (shouldAddDefault && openBrace == null && shouldAddSpecifiers) { // `import './foo'` → `import def, {...} from './foo'` fixes.push( @@ -203,68 +255,28 @@ function getFix(first, rest, sourceCode, context) { }; } -function isPunctuator(node, value) { - return node.type === 'Punctuator' && node.value === value; -} - -// Get the name of the default import of `node`, if any. -function getDefaultImportName(node) { - const defaultSpecifier = node.specifiers - .find((specifier) => specifier.type === 'ImportDefaultSpecifier'); - return defaultSpecifier != null ? defaultSpecifier.local.name : undefined; -} - -// Checks whether `node` has a namespace import. -function hasNamespace(node) { - const specifiers = node.specifiers - .filter((specifier) => specifier.type === 'ImportNamespaceSpecifier'); - return specifiers.length > 0; -} - -// Checks whether `node` has any non-default specifiers. -function hasSpecifiers(node) { - const specifiers = node.specifiers - .filter((specifier) => specifier.type === 'ImportSpecifier'); - return specifiers.length > 0; -} - -// It's not obvious what the user wants to do with comments associated with -// duplicate imports, so skip imports with comments when autofixing. -function hasProblematicComments(node, sourceCode) { - return ( - hasCommentBefore(node, sourceCode) - || hasCommentAfter(node, sourceCode) - || hasCommentInsideNonSpecifiers(node, sourceCode) - ); -} - -// Checks whether `node` has a comment (that ends) on the previous line or on -// the same line as `node` (starts). -function hasCommentBefore(node, sourceCode) { - return sourceCode.getCommentsBefore(node) - .some((comment) => comment.loc.end.line >= node.loc.start.line - 1); -} +function checkImports(imported, context) { + for (const [module, nodes] of imported.entries()) { + if (nodes.length > 1) { + const message = `'${module}' imported multiple times.`; + const [first, ...rest] = nodes; + const sourceCode = context.getSourceCode(); + const fix = getFix(first, rest, sourceCode, context); -// Checks whether `node` has a comment (that starts) on the same line as `node` -// (ends). -function hasCommentAfter(node, sourceCode) { - return sourceCode.getCommentsAfter(node) - .some((comment) => comment.loc.start.line === node.loc.end.line); -} + context.report({ + node: first.source, + message, + fix, // Attach the autofix (if any) to the first import. + }); -// Checks whether `node` has any comments _inside,_ except inside the `{...}` -// part (if any). -function hasCommentInsideNonSpecifiers(node, sourceCode) { - const tokens = sourceCode.getTokens(node); - const openBraceIndex = tokens.findIndex((token) => isPunctuator(token, '{')); - const closeBraceIndex = tokens.findIndex((token) => isPunctuator(token, '}')); - // Slice away the first token, since we're no looking for comments _before_ - // `node` (only inside). If there's a `{...}` part, look for comments before - // the `{`, but not before the `}` (hence the `+1`s). - const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0 - ? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1)) - : tokens.slice(1); - return someTokens.some((token) => sourceCode.getCommentsBefore(token).length > 0); + for (const node of rest) { + context.report({ + node: node.source, + message, + }); + } + } + } } module.exports = { diff --git a/src/rules/no-extraneous-dependencies.js b/src/rules/no-extraneous-dependencies.js index df97987901..0fe42f56f8 100644 --- a/src/rules/no-extraneous-dependencies.js +++ b/src/rules/no-extraneous-dependencies.js @@ -42,8 +42,11 @@ function extractDepFields(pkg) { function getPackageDepFields(packageJsonPath, throwAtRead) { if (!depFieldCache.has(packageJsonPath)) { - const depFields = extractDepFields(readJSON(packageJsonPath, throwAtRead)); - depFieldCache.set(packageJsonPath, depFields); + const packageJson = readJSON(packageJsonPath, throwAtRead); + if (packageJson) { + const depFields = extractDepFields(packageJson); + depFieldCache.set(packageJsonPath, depFields); + } } return depFieldCache.get(packageJsonPath); @@ -72,10 +75,12 @@ function getDependencies(context, packageDir) { // use rule config to find package.json paths.forEach((dir) => { const packageJsonPath = path.join(dir, 'package.json'); - const _packageContent = getPackageDepFields(packageJsonPath, true); - Object.keys(packageContent).forEach((depsKey) => { - Object.assign(packageContent[depsKey], _packageContent[depsKey]); - }); + const _packageContent = getPackageDepFields(packageJsonPath, paths.length === 1); + if (_packageContent) { + Object.keys(packageContent).forEach((depsKey) => { + Object.assign(packageContent[depsKey], _packageContent[depsKey]); + }); + } }); } else { const packageJsonPath = pkgUp({ diff --git a/src/rules/no-named-as-default-member.js b/src/rules/no-named-as-default-member.js index e00a4cbc87..54bec64a2a 100644 --- a/src/rules/no-named-as-default-member.js +++ b/src/rules/no-named-as-default-member.js @@ -4,7 +4,7 @@ * @copyright 2016 Desmond Brand. All rights reserved. * See LICENSE in root directory for full license. */ -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; @@ -36,7 +36,7 @@ module.exports = { return { ImportDefaultSpecifier(node) { const declaration = importDeclaration(context); - const exportMap = Exports.get(declaration.source.value, context); + const exportMap = ExportMapBuilder.get(declaration.source.value, context); if (exportMap == null) { return; } if (exportMap.errors.length) { diff --git a/src/rules/no-named-as-default.js b/src/rules/no-named-as-default.js index 40b1e175b2..5b24f8e883 100644 --- a/src/rules/no-named-as-default.js +++ b/src/rules/no-named-as-default.js @@ -1,4 +1,4 @@ -import Exports from '../ExportMap'; +import ExportMapBuilder from '../exportMap/builder'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; @@ -20,7 +20,7 @@ module.exports = { const declaration = importDeclaration(context); - const imports = Exports.get(declaration.source.value, context); + const imports = ExportMapBuilder.get(declaration.source.value, context); if (imports == null) { return; } if (imports.errors.length) { diff --git a/src/rules/no-namespace.js b/src/rules/no-namespace.js index d3e591876f..3c6617a41c 100644 --- a/src/rules/no-namespace.js +++ b/src/rules/no-namespace.js @@ -6,9 +6,74 @@ import minimatch from 'minimatch'; import docsUrl from '../docsUrl'; -//------------------------------------------------------------------------------ -// Rule Definition -//------------------------------------------------------------------------------ +/** + * @param {MemberExpression} memberExpression + * @returns {string} the name of the member in the object expression, e.g. the `x` in `namespace.x` + */ +function getMemberPropertyName(memberExpression) { + return memberExpression.property.type === 'Identifier' + ? memberExpression.property.name + : memberExpression.property.value; +} + +/** + * @param {ScopeManager} scopeManager + * @param {ASTNode} node + * @return {Set} + */ +function getVariableNamesInScope(scopeManager, node) { + let currentNode = node; + let scope = scopeManager.acquire(currentNode); + while (scope == null) { + currentNode = currentNode.parent; + scope = scopeManager.acquire(currentNode, true); + } + return new Set(scope.variables.concat(scope.upper.variables).map((variable) => variable.name)); +} + +/** + * + * @param {*} names + * @param {*} nameConflicts + * @param {*} namespaceName + */ +function generateLocalNames(names, nameConflicts, namespaceName) { + const localNames = {}; + names.forEach((name) => { + let localName; + if (!nameConflicts[name].has(name)) { + localName = name; + } else if (!nameConflicts[name].has(`${namespaceName}_${name}`)) { + localName = `${namespaceName}_${name}`; + } else { + for (let i = 1; i < Infinity; i++) { + if (!nameConflicts[name].has(`${namespaceName}_${name}_${i}`)) { + localName = `${namespaceName}_${name}_${i}`; + break; + } + } + } + localNames[name] = localName; + }); + return localNames; +} + +/** + * @param {Identifier[]} namespaceIdentifiers + * @returns {boolean} `true` if the namespace variable is more than just a glorified constant + */ +function usesNamespaceAsObject(namespaceIdentifiers) { + return !namespaceIdentifiers.every((identifier) => { + const parent = identifier.parent; + + // `namespace.x` or `namespace['x']` + return ( + parent + && parent.type === 'MemberExpression' + && (parent.property.type === 'Identifier' || parent.property.type === 'Literal') + ); + }); +} module.exports = { meta: { @@ -103,72 +168,3 @@ module.exports = { }; }, }; - -/** - * @param {Identifier[]} namespaceIdentifiers - * @returns {boolean} `true` if the namespace variable is more than just a glorified constant - */ -function usesNamespaceAsObject(namespaceIdentifiers) { - return !namespaceIdentifiers.every((identifier) => { - const parent = identifier.parent; - - // `namespace.x` or `namespace['x']` - return ( - parent - && parent.type === 'MemberExpression' - && (parent.property.type === 'Identifier' || parent.property.type === 'Literal') - ); - }); -} - -/** - * @param {MemberExpression} memberExpression - * @returns {string} the name of the member in the object expression, e.g. the `x` in `namespace.x` - */ -function getMemberPropertyName(memberExpression) { - return memberExpression.property.type === 'Identifier' - ? memberExpression.property.name - : memberExpression.property.value; -} - -/** - * @param {ScopeManager} scopeManager - * @param {ASTNode} node - * @return {Set} - */ -function getVariableNamesInScope(scopeManager, node) { - let currentNode = node; - let scope = scopeManager.acquire(currentNode); - while (scope == null) { - currentNode = currentNode.parent; - scope = scopeManager.acquire(currentNode, true); - } - return new Set(scope.variables.concat(scope.upper.variables).map((variable) => variable.name)); -} - -/** - * - * @param {*} names - * @param {*} nameConflicts - * @param {*} namespaceName - */ -function generateLocalNames(names, nameConflicts, namespaceName) { - const localNames = {}; - names.forEach((name) => { - let localName; - if (!nameConflicts[name].has(name)) { - localName = name; - } else if (!nameConflicts[name].has(`${namespaceName}_${name}`)) { - localName = `${namespaceName}_${name}`; - } else { - for (let i = 1; i < Infinity; i++) { - if (!nameConflicts[name].has(`${namespaceName}_${name}_${i}`)) { - localName = `${namespaceName}_${name}_${i}`; - break; - } - } - } - localNames[name] = localName; - }); - return localNames; -} diff --git a/src/rules/no-restricted-paths.js b/src/rules/no-restricted-paths.js index cd680a1946..75952dd058 100644 --- a/src/rules/no-restricted-paths.js +++ b/src/rules/no-restricted-paths.js @@ -12,6 +12,15 @@ const containsPath = (filepath, target) => { return relative === '' || !relative.startsWith('..'); }; +function isMatchingTargetPath(filename, targetPath) { + if (isGlob(targetPath)) { + const mm = new Minimatch(targetPath); + return mm.match(filename); + } + + return containsPath(filename, targetPath); +} + module.exports = { meta: { type: 'problem', @@ -83,15 +92,6 @@ module.exports = { .some((targetPath) => isMatchingTargetPath(currentFilename, targetPath)), ); - function isMatchingTargetPath(filename, targetPath) { - if (isGlob(targetPath)) { - const mm = new Minimatch(targetPath); - return mm.match(filename); - } - - return containsPath(filename, targetPath); - } - function isValidExceptionPath(absoluteFromPath, absoluteExceptionPath) { const relativeExceptionPath = path.relative(absoluteFromPath, absoluteExceptionPath); diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 8229d880ce..702f2f8899 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -7,60 +7,177 @@ import { getFileExtensions } from 'eslint-module-utils/ignore'; import resolve from 'eslint-module-utils/resolve'; import visit from 'eslint-module-utils/visit'; -import { dirname, join } from 'path'; +import { dirname, join, resolve as resolvePath } from 'path'; import readPkgUp from 'eslint-module-utils/readPkgUp'; import values from 'object.values'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; -import Exports, { recursivePatternCapture } from '../ExportMap'; +import { walkSync } from '../core/fsWalk'; +import ExportMapBuilder from '../exportMap/builder'; +import recursivePatternCapture from '../exportMap/patternCapture'; import docsUrl from '../docsUrl'; -let FileEnumerator; -let listFilesToProcess; +/** + * Attempt to load the internal `FileEnumerator` class, which has existed in a couple + * of different places, depending on the version of `eslint`. Try requiring it from both + * locations. + * @returns Returns the `FileEnumerator` class if its requirable, otherwise `undefined`. + */ +function requireFileEnumerator() { + let FileEnumerator; -try { - ({ FileEnumerator } = require('eslint/use-at-your-own-risk')); -} catch (e) { + // Try getting it from the eslint private / deprecated api try { - // has been moved to eslint/lib/cli-engine/file-enumerator in version 6 - ({ FileEnumerator } = require('eslint/lib/cli-engine/file-enumerator')); + ({ FileEnumerator } = require('eslint/use-at-your-own-risk')); } catch (e) { + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + + // If not there, then try getting it from eslint/lib/cli-engine/file-enumerator (moved there in v6) try { - // eslint/lib/util/glob-util has been moved to eslint/lib/util/glob-utils with version 5.3 - const { listFilesToProcess: originalListFilesToProcess } = require('eslint/lib/util/glob-utils'); - - // Prevent passing invalid options (extensions array) to old versions of the function. - // https://github.com/eslint/eslint/blob/v5.16.0/lib/util/glob-utils.js#L178-L280 - // https://github.com/eslint/eslint/blob/v5.2.0/lib/util/glob-util.js#L174-L269 - listFilesToProcess = function (src, extensions) { - return originalListFilesToProcess(src, { - extensions, - }); - }; + ({ FileEnumerator } = require('eslint/lib/cli-engine/file-enumerator')); } catch (e) { - const { listFilesToProcess: originalListFilesToProcess } = require('eslint/lib/util/glob-util'); + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } + } + return FileEnumerator; +} - listFilesToProcess = function (src, extensions) { - const patterns = src.concat(flatMap(src, (pattern) => extensions.map((extension) => (/\*\*|\*\./).test(pattern) ? pattern : `${pattern}/**/*${extension}`))); +/** + * + * @param FileEnumerator the `FileEnumerator` class from `eslint`'s internal api + * @param {string} src path to the src root + * @param {string[]} extensions list of supported extensions + * @returns {{ filename: string, ignored: boolean }[]} list of files to operate on + */ +function listFilesUsingFileEnumerator(FileEnumerator, src, extensions) { + const e = new FileEnumerator({ + extensions, + }); - return originalListFilesToProcess(patterns); - }; + return Array.from( + e.iterateFiles(src), + ({ filePath, ignored }) => ({ filename: filePath, ignored }), + ); +} + +/** + * Attempt to require old versions of the file enumeration capability from v6 `eslint` and earlier, and use + * those functions to provide the list of files to operate on + * @param {string} src path to the src root + * @param {string[]} extensions list of supported extensions + * @returns {string[]} list of files to operate on + */ +function listFilesWithLegacyFunctions(src, extensions) { + try { + // eslint/lib/util/glob-util has been moved to eslint/lib/util/glob-utils with version 5.3 + const { listFilesToProcess: originalListFilesToProcess } = require('eslint/lib/util/glob-utils'); + // Prevent passing invalid options (extensions array) to old versions of the function. + // https://github.com/eslint/eslint/blob/v5.16.0/lib/util/glob-utils.js#L178-L280 + // https://github.com/eslint/eslint/blob/v5.2.0/lib/util/glob-util.js#L174-L269 + + return originalListFilesToProcess(src, { + extensions, + }); + } catch (e) { + // Absorb this if it's MODULE_NOT_FOUND + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; } + + // Last place to try (pre v5.3) + const { + listFilesToProcess: originalListFilesToProcess, + } = require('eslint/lib/util/glob-util'); + const patterns = src.concat( + flatMap( + src, + (pattern) => extensions.map((extension) => (/\*\*|\*\./).test(pattern) ? pattern : `${pattern}/**/*${extension}`), + ), + ); + + return originalListFilesToProcess(patterns); } } -if (FileEnumerator) { - listFilesToProcess = function (src, extensions) { - const e = new FileEnumerator({ - extensions, +/** + * Given a source root and list of supported extensions, use fsWalk and the + * new `eslint` `context.session` api to build the list of files we want to operate on + * @param {string[]} srcPaths array of source paths (for flat config this should just be a singular root (e.g. cwd)) + * @param {string[]} extensions list of supported extensions + * @param {{ isDirectoryIgnored: (path: string) => boolean, isFileIgnored: (path: string) => boolean }} session eslint context session object + * @returns {string[]} list of files to operate on + */ +function listFilesWithModernApi(srcPaths, extensions, session) { + /** @type {string[]} */ + const files = []; + + for (let i = 0; i < srcPaths.length; i++) { + const src = srcPaths[i]; + // Use walkSync along with the new session api to gather the list of files + const entries = walkSync(src, { + deepFilter(entry) { + const fullEntryPath = resolvePath(src, entry.path); + + // Include the directory if it's not marked as ignore by eslint + return !session.isDirectoryIgnored(fullEntryPath); + }, + entryFilter(entry) { + const fullEntryPath = resolvePath(src, entry.path); + + // Include the file if it's not marked as ignore by eslint and its extension is included in our list + return ( + !session.isFileIgnored(fullEntryPath) + && extensions.find((extension) => entry.path.endsWith(extension)) + ); + }, }); - return Array.from(e.iterateFiles(src), ({ filePath, ignored }) => ({ - ignored, - filename: filePath, - })); - }; + // Filter out directories and map entries to their paths + files.push( + ...entries + .filter((entry) => !entry.dirent.isDirectory()) + .map((entry) => entry.path), + ); + } + return files; +} + +/** + * Given a src pattern and list of supported extensions, return a list of files to process + * with this rule. + * @param {string} src - file, directory, or glob pattern of files to act on + * @param {string[]} extensions - list of supported file extensions + * @param {import('eslint').Rule.RuleContext} context - the eslint context object + * @returns {string[] | { filename: string, ignored: boolean }[]} the list of files that this rule will evaluate. + */ +function listFilesToProcess(src, extensions, context) { + // If the context object has the new session functions, then prefer those + // Otherwise, fallback to using the deprecated `FileEnumerator` for legacy support. + // https://github.com/eslint/eslint/issues/18087 + if ( + context.session + && context.session.isFileIgnored + && context.session.isDirectoryIgnored + ) { + return listFilesWithModernApi(src, extensions, context.session); + } + + // Fallback to og FileEnumerator + const FileEnumerator = requireFileEnumerator(); + + // If we got the FileEnumerator, then let's go with that + if (FileEnumerator) { + return listFilesUsingFileEnumerator(FileEnumerator, src, extensions); + } + // If not, then we can try even older versions of this capability (listFilesToProcess) + return listFilesWithLegacyFunctions(src, extensions); } const EXPORT_DEFAULT_DECLARATION = 'ExportDefaultDeclaration'; @@ -82,28 +199,30 @@ const DEFAULT = 'default'; function forEachDeclarationIdentifier(declaration, cb) { if (declaration) { + const isTypeDeclaration = declaration.type === TS_INTERFACE_DECLARATION + || declaration.type === TS_TYPE_ALIAS_DECLARATION + || declaration.type === TS_ENUM_DECLARATION; + if ( declaration.type === FUNCTION_DECLARATION || declaration.type === CLASS_DECLARATION - || declaration.type === TS_INTERFACE_DECLARATION - || declaration.type === TS_TYPE_ALIAS_DECLARATION - || declaration.type === TS_ENUM_DECLARATION + || isTypeDeclaration ) { - cb(declaration.id.name); + cb(declaration.id.name, isTypeDeclaration); } else if (declaration.type === VARIABLE_DECLARATION) { declaration.declarations.forEach(({ id }) => { if (id.type === OBJECT_PATTERN) { recursivePatternCapture(id, (pattern) => { if (pattern.type === IDENTIFIER) { - cb(pattern.name); + cb(pattern.name, false); } }); } else if (id.type === ARRAY_PATTERN) { id.elements.forEach(({ name }) => { - cb(name); + cb(name, false); }); } else { - cb(id.name); + cb(id.name, false); } }); } @@ -160,6 +279,7 @@ const exportList = new Map(); const visitorKeyMap = new Map(); +/** @type {Set} */ const ignoredFiles = new Set(); const filesOutsideSrc = new Set(); @@ -169,22 +289,30 @@ const isNodeModule = (path) => (/\/(node_modules)\//).test(path); * read all files matching the patterns in src and ignoreExports * * return all files matching src pattern, which are not matching the ignoreExports pattern + * @type {(src: string, ignoreExports: string, context: import('eslint').Rule.RuleContext) => Set} */ -const resolveFiles = (src, ignoreExports, context) => { +function resolveFiles(src, ignoreExports, context) { const extensions = Array.from(getFileExtensions(context.settings)); - const srcFileList = listFilesToProcess(src, extensions); + const srcFileList = listFilesToProcess(src, extensions, context); // prepare list of ignored files - const ignoredFilesList = listFilesToProcess(ignoreExports, extensions); - ignoredFilesList.forEach(({ filename }) => ignoredFiles.add(filename)); + const ignoredFilesList = listFilesToProcess(ignoreExports, extensions, context); + + // The modern api will return a list of file paths, rather than an object + if (ignoredFilesList.length && typeof ignoredFilesList[0] === 'string') { + ignoredFilesList.forEach((filename) => ignoredFiles.add(filename)); + } else { + ignoredFilesList.forEach(({ filename }) => ignoredFiles.add(filename)); + } // prepare list of source files, don't consider files from node_modules + const resolvedFiles = srcFileList.length && typeof srcFileList[0] === 'string' + ? srcFileList.filter((filePath) => !isNodeModule(filePath)) + : flatMap(srcFileList, ({ filename }) => isNodeModule(filename) ? [] : filename); - return new Set( - flatMap(srcFileList, ({ filename }) => isNodeModule(filename) ? [] : filename), - ); -}; + return new Set(resolvedFiles); +} /** * parse all source files and build up 2 maps containing the existing imports and exports @@ -194,7 +322,7 @@ const prepareImportsAndExports = (srcFiles, context) => { srcFiles.forEach((file) => { const exports = new Map(); const imports = new Map(); - const currentExports = Exports.get(file, context); + const currentExports = ExportMapBuilder.get(file, context); if (currentExports) { const { dependencies, @@ -223,7 +351,7 @@ const prepareImportsAndExports = (srcFiles, context) => { } else { exports.set(key, { whereUsed: new Set() }); } - const reexport = value.getImport(); + const reexport = value.getImport(); if (!reexport) { return; } @@ -326,6 +454,7 @@ const getSrc = (src) => { * prepare the lists of existing imports and exports - should only be executed once at * the start of a new eslint run */ +/** @type {Set} */ let srcFiles; let lastPrepareKey; const doPreparation = (src, ignoreExports, context) => { @@ -442,6 +571,10 @@ module.exports = { description: 'report exports without any usage', type: 'boolean', }, + ignoreUnusedTypeExports: { + description: 'ignore type exports without any usage', + type: 'boolean', + }, }, anyOf: [ { @@ -469,6 +602,7 @@ module.exports = { ignoreExports = [], missingExports, unusedExports, + ignoreUnusedTypeExports, } = context.options[0] || {}; if (unusedExports) { @@ -501,11 +635,15 @@ module.exports = { exportCount.set(IMPORT_NAMESPACE_SPECIFIER, namespaceImports); }; - const checkUsage = (node, exportedValue) => { + const checkUsage = (node, exportedValue, isTypeExport) => { if (!unusedExports) { return; } + if (isTypeExport && ignoreUnusedTypeExports) { + return; + } + if (ignoredFiles.has(file)) { return; } @@ -529,6 +667,10 @@ module.exports = { exports = exportList.get(file); + if (!exports) { + console.error(`file \`${file}\` has no exports. Please update to the latest, and if it still happens, report this on https://github.com/import-js/eslint-plugin-import/issues/2866!`); + } + // special case: export * from const exportAll = exports.get(EXPORT_ALL_DECLARATION); if (typeof exportAll !== 'undefined' && exportedValue !== IMPORT_DEFAULT_SPECIFIER) { @@ -930,14 +1072,14 @@ module.exports = { checkExportPresence(node); }, ExportDefaultDeclaration(node) { - checkUsage(node, IMPORT_DEFAULT_SPECIFIER); + checkUsage(node, IMPORT_DEFAULT_SPECIFIER, false); }, ExportNamedDeclaration(node) { node.specifiers.forEach((specifier) => { - checkUsage(specifier, specifier.exported.name || specifier.exported.value); + checkUsage(specifier, specifier.exported.name || specifier.exported.value, false); }); - forEachDeclarationIdentifier(node.declaration, (name) => { - checkUsage(node, name); + forEachDeclarationIdentifier(node.declaration, (name, isTypeExport) => { + checkUsage(node, name, isTypeExport); }); }, }; diff --git a/src/rules/order.js b/src/rules/order.js index 44d25be63c..1b25273c65 100644 --- a/src/rules/order.js +++ b/src/rules/order.js @@ -92,6 +92,12 @@ function findRootNode(node) { return parent; } +function commentOnSameLineAs(node) { + return (token) => (token.type === 'Block' || token.type === 'Line') + && token.loc.start.line === token.loc.end.line + && token.loc.end.line === node.loc.end.line; +} + function findEndOfLineWithComments(sourceCode, node) { const tokensToEndOfLine = takeTokensAfterWhile(sourceCode, node, commentOnSameLineAs(node)); const endOfTokens = tokensToEndOfLine.length > 0 @@ -111,12 +117,6 @@ function findEndOfLineWithComments(sourceCode, node) { return result; } -function commentOnSameLineAs(node) { - return (token) => (token.type === 'Block' || token.type === 'Line') - && token.loc.start.line === token.loc.end.line - && token.loc.end.line === node.loc.end.line; -} - function findStartOfLineWithComments(sourceCode, node) { const tokensToEndOfLine = takeTokensBeforeWhile(sourceCode, node, commentOnSameLineAs(node)); const startOfTokens = tokensToEndOfLine.length > 0 ? tokensToEndOfLine[0].range[0] : node.range[0]; @@ -302,6 +302,12 @@ function getSorter(alphabetizeOptions) { const b = B.length; for (let i = 0; i < Math.min(a, b); i++) { + // Skip comparing the first path segment, if they are relative segments for both imports + if (i === 0 && ((A[i] === '.' || A[i] === '..') && (B[i] === '.' || B[i] === '..'))) { + // If one is sibling and the other parent import, no need to compare at all, since the paths belong in different groups + if (A[i] !== B[i]) { break; } + continue; + } result = compareString(A[i], B[i]); if (result) { break; } } diff --git a/src/scc.js b/src/scc.js new file mode 100644 index 0000000000..44c818bbe1 --- /dev/null +++ b/src/scc.js @@ -0,0 +1,86 @@ +import calculateScc from '@rtsao/scc'; +import { hashObject } from 'eslint-module-utils/hash'; +import resolve from 'eslint-module-utils/resolve'; +import ExportMapBuilder from './exportMap/builder'; +import childContext from './exportMap/childContext'; + +let cache = new Map(); + +export default class StronglyConnectedComponentsBuilder { + static clearCache() { + cache = new Map(); + } + + static get(source, context) { + const path = resolve(source, context); + if (path == null) { return null; } + return StronglyConnectedComponentsBuilder.for(childContext(path, context)); + } + + static for(context) { + const cacheKey = context.cacheKey || hashObject(context).digest('hex'); + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + const scc = StronglyConnectedComponentsBuilder.calculate(context); + cache.set(cacheKey, scc); + return scc; + } + + static calculate(context) { + const exportMap = ExportMapBuilder.for(context); + const adjacencyList = this.exportMapToAdjacencyList(exportMap); + const calculatedScc = calculateScc(adjacencyList); + return StronglyConnectedComponentsBuilder.calculatedSccToPlainObject(calculatedScc); + } + + /** @returns {Map>} for each dep, what are its direct deps */ + static exportMapToAdjacencyList(initialExportMap) { + const adjacencyList = new Map(); + // BFS + function visitNode(exportMap) { + if (!exportMap) { + return; + } + exportMap.imports.forEach((v, importedPath) => { + const from = exportMap.path; + const to = importedPath; + + // Ignore type-only imports, because we care only about SCCs of value imports + const toTraverse = [...v.declarations].filter(({ isOnlyImportingTypes }) => !isOnlyImportingTypes); + if (toTraverse.length === 0) { return; } + + if (!adjacencyList.has(from)) { + adjacencyList.set(from, new Set()); + } + + if (adjacencyList.get(from).has(to)) { + return; // prevent endless loop + } + adjacencyList.get(from).add(to); + visitNode(v.getter()); + }); + } + visitNode(initialExportMap); + // Fill gaps + adjacencyList.forEach((values) => { + values.forEach((value) => { + if (!adjacencyList.has(value)) { + adjacencyList.set(value, new Set()); + } + }); + }); + return adjacencyList; + } + + /** @returns {Record} for each key, its SCC's index */ + static calculatedSccToPlainObject(sccs) { + const obj = {}; + sccs.forEach((scc, index) => { + scc.forEach((node) => { + obj[node] = index; + }); + }); + return obj; + } +} diff --git a/tests/src/core/getExports.js b/tests/src/core/getExports.js index 1dd6e88014..76003410d5 100644 --- a/tests/src/core/getExports.js +++ b/tests/src/core/getExports.js @@ -4,7 +4,7 @@ import sinon from 'sinon'; import eslintPkg from 'eslint/package.json'; import typescriptPkg from 'typescript/package.json'; import * as tsConfigLoader from 'tsconfig-paths/lib/tsconfig-loader'; -import ExportMap from '../../../src/ExportMap'; +import ExportMapBuilder from '../../../src/exportMap/builder'; import * as fs from 'fs'; @@ -28,7 +28,7 @@ describe('ExportMap', function () { it('handles ExportAllDeclaration', function () { let imports; expect(function () { - imports = ExportMap.get('./export-all', fakeContext); + imports = ExportMapBuilder.get('./export-all', fakeContext); }).not.to.throw(Error); expect(imports).to.exist; @@ -37,25 +37,25 @@ describe('ExportMap', function () { }); it('returns a cached copy on subsequent requests', function () { - expect(ExportMap.get('./named-exports', fakeContext)) - .to.exist.and.equal(ExportMap.get('./named-exports', fakeContext)); + expect(ExportMapBuilder.get('./named-exports', fakeContext)) + .to.exist.and.equal(ExportMapBuilder.get('./named-exports', fakeContext)); }); it('does not return a cached copy after modification', (done) => { - const firstAccess = ExportMap.get('./mutator', fakeContext); + const firstAccess = ExportMapBuilder.get('./mutator', fakeContext); expect(firstAccess).to.exist; // mutate (update modified time) const newDate = new Date(); fs.utimes(getFilename('mutator.js'), newDate, newDate, (error) => { expect(error).not.to.exist; - expect(ExportMap.get('./mutator', fakeContext)).not.to.equal(firstAccess); + expect(ExportMapBuilder.get('./mutator', fakeContext)).not.to.equal(firstAccess); done(); }); }); it('does not return a cached copy with different settings', () => { - const firstAccess = ExportMap.get('./named-exports', fakeContext); + const firstAccess = ExportMapBuilder.get('./named-exports', fakeContext); expect(firstAccess).to.exist; const differentSettings = { @@ -63,7 +63,7 @@ describe('ExportMap', function () { parserPath: 'espree', }; - expect(ExportMap.get('./named-exports', differentSettings)) + expect(ExportMapBuilder.get('./named-exports', differentSettings)) .to.exist.and .not.to.equal(firstAccess); }); @@ -71,7 +71,7 @@ describe('ExportMap', function () { it('does not throw for a missing file', function () { let imports; expect(function () { - imports = ExportMap.get('./does-not-exist', fakeContext); + imports = ExportMapBuilder.get('./does-not-exist', fakeContext); }).not.to.throw(Error); expect(imports).not.to.exist; @@ -81,7 +81,7 @@ describe('ExportMap', function () { it('exports explicit names for a missing file in exports', function () { let imports; expect(function () { - imports = ExportMap.get('./exports-missing', fakeContext); + imports = ExportMapBuilder.get('./exports-missing', fakeContext); }).not.to.throw(Error); expect(imports).to.exist; @@ -92,7 +92,7 @@ describe('ExportMap', function () { it('finds exports for an ES7 module with babel-eslint', function () { const path = getFilename('jsx/FooES7.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const imports = ExportMap.parse( + const imports = ExportMapBuilder.parse( path, contents, { parserPath: 'babel-eslint', settings: {} }, @@ -112,7 +112,7 @@ describe('ExportMap', function () { before('parse file', function () { const path = getFilename('deprecated.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }).replace(/[\r]\n/g, lineEnding); - imports = ExportMap.parse(path, contents, parseContext); + imports = ExportMapBuilder.parse(path, contents, parseContext); // sanity checks expect(imports.errors).to.be.empty; @@ -181,7 +181,7 @@ describe('ExportMap', function () { before('parse file', function () { const path = getFilename('deprecated-file.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - imports = ExportMap.parse(path, contents, parseContext); + imports = ExportMapBuilder.parse(path, contents, parseContext); // sanity checks expect(imports.errors).to.be.empty; @@ -243,7 +243,7 @@ describe('ExportMap', function () { it('works with espree & traditional namespace exports', function () { const path = getFilename('deep/a.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const a = ExportMap.parse(path, contents, espreeContext); + const a = ExportMapBuilder.parse(path, contents, espreeContext); expect(a.errors).to.be.empty; expect(a.get('b').namespace).to.exist; expect(a.get('b').namespace.has('c')).to.be.true; @@ -252,7 +252,7 @@ describe('ExportMap', function () { it('captures namespace exported as default', function () { const path = getFilename('deep/default.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const def = ExportMap.parse(path, contents, espreeContext); + const def = ExportMapBuilder.parse(path, contents, espreeContext); expect(def.errors).to.be.empty; expect(def.get('default').namespace).to.exist; expect(def.get('default').namespace.has('c')).to.be.true; @@ -261,7 +261,7 @@ describe('ExportMap', function () { it('works with babel-eslint & ES7 namespace exports', function () { const path = getFilename('deep-es7/a.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - const a = ExportMap.parse(path, contents, babelContext); + const a = ExportMapBuilder.parse(path, contents, babelContext); expect(a.errors).to.be.empty; expect(a.get('b').namespace).to.exist; expect(a.get('b').namespace.has('c')).to.be.true; @@ -278,7 +278,7 @@ describe('ExportMap', function () { const path = getFilename('deep/cache-1.js'); const contents = fs.readFileSync(path, { encoding: 'utf8' }); - a = ExportMap.parse(path, contents, espreeContext); + a = ExportMapBuilder.parse(path, contents, espreeContext); expect(a.errors).to.be.empty; expect(a.get('b').namespace).to.exist; @@ -304,10 +304,10 @@ describe('ExportMap', function () { context('Map API', function () { context('#size', function () { - it('counts the names', () => expect(ExportMap.get('./named-exports', fakeContext)) + it('counts the names', () => expect(ExportMapBuilder.get('./named-exports', fakeContext)) .to.have.property('size', 12)); - it('includes exported namespace size', () => expect(ExportMap.get('./export-all', fakeContext)) + it('includes exported namespace size', () => expect(ExportMapBuilder.get('./export-all', fakeContext)) .to.have.property('size', 1)); }); @@ -315,14 +315,14 @@ describe('ExportMap', function () { context('issue #210: self-reference', function () { it(`doesn't crash`, function () { - expect(() => ExportMap.get('./narcissist', fakeContext)).not.to.throw(Error); + expect(() => ExportMapBuilder.get('./narcissist', fakeContext)).not.to.throw(Error); }); it(`'has' circular reference`, function () { - expect(ExportMap.get('./narcissist', fakeContext)) + expect(ExportMapBuilder.get('./narcissist', fakeContext)) .to.exist.and.satisfy((m) => m.has('soGreat')); }); it(`can 'get' circular reference`, function () { - expect(ExportMap.get('./narcissist', fakeContext)) + expect(ExportMapBuilder.get('./narcissist', fakeContext)) .to.exist.and.satisfy((m) => m.get('soGreat') != null); }); }); @@ -335,7 +335,7 @@ describe('ExportMap', function () { let imports; before('load imports', function () { - imports = ExportMap.get('./typescript.ts', context); + imports = ExportMapBuilder.get('./typescript.ts', context); }); it('returns nothing for a TypeScript file', function () { @@ -372,7 +372,7 @@ describe('ExportMap', function () { before('load imports', function () { this.timeout(20e3); // takes a long time :shrug: sinon.spy(tsConfigLoader, 'tsConfigLoader'); - imports = ExportMap.get('./typescript.ts', context); + imports = ExportMapBuilder.get('./typescript.ts', context); }); after('clear spies', function () { tsConfigLoader.tsConfigLoader.restore(); @@ -414,9 +414,9 @@ describe('ExportMap', function () { }, }; expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(0); - ExportMap.parse('./baz.ts', 'export const baz = 5', customContext); + ExportMapBuilder.parse('./baz.ts', 'export const baz = 5', customContext); expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(1); - ExportMap.parse('./baz.ts', 'export const baz = 5', customContext); + ExportMapBuilder.parse('./baz.ts', 'export const baz = 5', customContext); expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(1); const differentContext = { @@ -426,17 +426,17 @@ describe('ExportMap', function () { }, }; - ExportMap.parse('./baz.ts', 'export const baz = 5', differentContext); + ExportMapBuilder.parse('./baz.ts', 'export const baz = 5', differentContext); expect(tsConfigLoader.tsConfigLoader.callCount).to.equal(2); }); it('should cache after parsing for an ambiguous module', function () { const source = './typescript-declare-module.ts'; - const parseSpy = sinon.spy(ExportMap, 'parse'); + const parseSpy = sinon.spy(ExportMapBuilder, 'parse'); - expect(ExportMap.get(source, context)).to.be.null; + expect(ExportMapBuilder.get(source, context)).to.be.null; - ExportMap.get(source, context); + ExportMapBuilder.get(source, context); expect(parseSpy.callCount).to.equal(1); diff --git a/tests/src/package.js b/tests/src/package.js index 08138084c6..c56bd1333d 100644 --- a/tests/src/package.js +++ b/tests/src/package.js @@ -45,6 +45,13 @@ describe('package', function () { }); }); + function getRulePath(ruleName) { + // 'require' does not work with dynamic paths because of the compilation step by babel + // (which resolves paths according to the root folder configuration) + // the usage of require.resolve on a static path gets around this + return path.resolve(require.resolve('rules/no-unresolved'), '..', ruleName); + } + it('has configs only for rules that exist', function () { for (const configFile in module.configs) { const preamble = 'import/'; @@ -54,13 +61,6 @@ describe('package', function () { .not.to.throw(Error); } } - - function getRulePath(ruleName) { - // 'require' does not work with dynamic paths because of the compilation step by babel - // (which resolves paths according to the root folder configuration) - // the usage of require.resolve on a static path gets around this - return path.resolve(require.resolve('rules/no-unresolved'), '..', ruleName); - } }); it('marks deprecated rules in their metadata', function () { diff --git a/tests/src/rules/dynamic-import-chunkname.js b/tests/src/rules/dynamic-import-chunkname.js index 73617a6f36..81e018af76 100644 --- a/tests/src/rules/dynamic-import-chunkname.js +++ b/tests/src/rules/dynamic-import-chunkname.js @@ -12,6 +12,10 @@ const pickyCommentOptions = [{ importFunctions: ['dynamicImport'], webpackChunknameFormat: pickyCommentFormat, }]; +const allowEmptyOptions = [{ + importFunctions: ['dynamicImport'], + allowEmpty: true, +}]; const multipleImportFunctionOptions = [{ importFunctions: ['dynamicImport', 'definitelyNotStaticImport'], }]; @@ -22,8 +26,9 @@ const nonBlockCommentError = 'dynamic imports require a /* foo */ style comment, const noPaddingCommentError = 'dynamic imports require a block comment padded with spaces - /* foo */'; const invalidSyntaxCommentError = 'dynamic imports require a "webpack" comment with valid syntax'; const commentFormatError = `dynamic imports require a "webpack" comment with valid syntax`; -const chunkNameFormatError = `dynamic imports require a leading comment in the form /* webpackChunkName: ["']${commentFormat}["'],? */`; -const pickyChunkNameFormatError = `dynamic imports require a leading comment in the form /* webpackChunkName: ["']${pickyCommentFormat}["'],? */`; +const chunkNameFormatError = `dynamic imports require a leading comment in the form /*webpackChunkName: ["']${commentFormat}["'],? */`; +const pickyChunkNameFormatError = `dynamic imports require a leading comment in the form /*webpackChunkName: ["']${pickyCommentFormat}["'],? */`; +const eagerModeError = `dynamic imports using eager mode do not need a webpackChunkName`; ruleTester.run('dynamic-import-chunkname', rule, { valid: [ @@ -83,6 +88,19 @@ ruleTester.run('dynamic-import-chunkname', rule, { )`, options, }, + { + code: `import('test')`, + options: allowEmptyOptions, + parser, + }, + { + code: `import( + /* webpackMode: "lazy" */ + 'test' + )`, + options: allowEmptyOptions, + parser, + }, { code: `import( /* webpackChunkName: "someModule" */ @@ -337,7 +355,6 @@ ruleTester.run('dynamic-import-chunkname', rule, { }, { code: `import( - /* webpackChunkName: "someModule" */ /* webpackMode: "eager" */ 'someModule' )`, @@ -395,7 +412,7 @@ ruleTester.run('dynamic-import-chunkname', rule, { /* webpackPrefetch: true */ /* webpackPreload: true */ /* webpackIgnore: false */ - /* webpackMode: "eager" */ + /* webpackMode: "lazy" */ /* webpackExports: ["default", "named"] */ 'someModule' )`, @@ -964,6 +981,42 @@ ruleTester.run('dynamic-import-chunkname', rule, { type: 'CallExpression', }], }, + { + code: `import( + /* webpackChunkName: "someModule" */ + /* webpackMode: "eager" */ + 'someModule' + )`, + options, + parser, + output: `import( + /* webpackChunkName: "someModule" */ + /* webpackMode: "eager" */ + 'someModule' + )`, + errors: [{ + message: eagerModeError, + type: 'CallExpression', + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: `import( + ${''} + /* webpackMode: "eager" */ + 'someModule' + )`, + }, + { + desc: 'Remove webpackMode', + output: `import( + /* webpackChunkName: "someModule" */ + ${''} + 'someModule' + )`, + }, + ], + }], + }, ], }); @@ -975,6 +1028,19 @@ context('TypeScript', () => { ruleTester.run('dynamic-import-chunkname', rule, { valid: [ + { + code: `import('test')`, + options: allowEmptyOptions, + parser: typescriptParser, + }, + { + code: `import( + /* webpackMode: "lazy" */ + 'test' + )`, + options: allowEmptyOptions, + parser: typescriptParser, + }, { code: `import( /* webpackChunkName: "someModule" */ @@ -1183,15 +1249,6 @@ context('TypeScript', () => { options, parser: typescriptParser, }, - { - code: `import( - /* webpackChunkName: "someModule" */ - /* webpackMode: "lazy" */ - 'someModule' - )`, - options, - parser: typescriptParser, - }, { code: `import( /* webpackChunkName: 'someModule', webpackMode: 'lazy' */ @@ -1212,7 +1269,7 @@ context('TypeScript', () => { { code: `import( /* webpackChunkName: "someModule" */ - /* webpackMode: "eager" */ + /* webpackMode: "lazy" */ 'someModule' )`, options, @@ -1269,13 +1326,21 @@ context('TypeScript', () => { /* webpackPrefetch: true */ /* webpackPreload: true */ /* webpackIgnore: false */ - /* webpackMode: "eager" */ + /* webpackMode: "lazy" */ /* webpackExports: ["default", "named"] */ 'someModule' )`, options, parser: typescriptParser, }, + { + code: `import( + /* webpackMode: "eager" */ + 'someModule' + )`, + options, + parser: typescriptParser, + }, ], invalid: [ { @@ -1722,6 +1787,162 @@ context('TypeScript', () => { type: nodeType, }], }, + { + code: `import( + /* webpackChunkName: "someModule", webpackMode: "eager" */ + 'someModule' + )`, + options, + parser: typescriptParser, + output: `import( + /* webpackChunkName: "someModule", webpackMode: "eager" */ + 'someModule' + )`, + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: `import( + /* webpackMode: "eager" */ + 'someModule' + )`, + }, + { + desc: 'Remove webpackMode', + output: `import( + /* webpackChunkName: "someModule" */ + 'someModule' + )`, + }, + ], + }], + }, + { + code: ` + import( + /* webpackMode: "eager", webpackChunkName: "someModule" */ + 'someModule' + ) + `, + options, + parser: typescriptParser, + output: ` + import( + /* webpackMode: "eager", webpackChunkName: "someModule" */ + 'someModule' + ) + `, + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: ` + import( + /* webpackMode: "eager" */ + 'someModule' + ) + `, + }, + { + desc: 'Remove webpackMode', + output: ` + import( + /* webpackChunkName: "someModule" */ + 'someModule' + ) + `, + }, + ], + }], + }, + { + code: ` + import( + /* webpackMode: "eager", webpackPrefetch: true, webpackChunkName: "someModule" */ + 'someModule' + ) + `, + options, + parser: typescriptParser, + output: ` + import( + /* webpackMode: "eager", webpackPrefetch: true, webpackChunkName: "someModule" */ + 'someModule' + ) + `, + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: ` + import( + /* webpackMode: "eager", webpackPrefetch: true */ + 'someModule' + ) + `, + }, + { + desc: 'Remove webpackMode', + output: ` + import( + /* webpackPrefetch: true, webpackChunkName: "someModule" */ + 'someModule' + ) + `, + }, + ], + }], + }, + { + code: ` + import( + /* webpackChunkName: "someModule" */ + /* webpackMode: "eager" */ + 'someModule' + ) + `, + options, + parser: typescriptParser, + output: ` + import( + /* webpackChunkName: "someModule" */ + /* webpackMode: "eager" */ + 'someModule' + ) + `, + errors: [{ + message: eagerModeError, + type: nodeType, + suggestions: [ + { + desc: 'Remove webpackChunkName', + output: ` + import( + ${''} + /* webpackMode: "eager" */ + 'someModule' + ) + `, + }, + { + desc: 'Remove webpackMode', + output: ` + import( + /* webpackChunkName: "someModule" */ + ${''} + 'someModule' + ) + `, + }, + ], + }], + }, ], }); }); diff --git a/tests/src/rules/namespace.js b/tests/src/rules/namespace.js index 1475ae9b7d..3f768a5717 100644 --- a/tests/src/rules/namespace.js +++ b/tests/src/rules/namespace.js @@ -336,10 +336,10 @@ const invalid = [].concat( test({ parser, code: `import { b } from "./${folder}/a"; console.log(b.c.d.e)` }), test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.c.d.e.f)` }), test({ parser, code: `import * as a from "./${folder}/a"; var {b:{c:{d:{e}}}} = a` }), - test({ parser, code: `import { b } from "./${folder}/a"; var {c:{d:{e}}} = b` })); - - // deep namespaces should include explicitly exported defaults - test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.default)` }), + test({ parser, code: `import { b } from "./${folder}/a"; var {c:{d:{e}}} = b` }), + // deep namespaces should include explicitly exported defaults + test({ parser, code: `import * as a from "./${folder}/a"; console.log(a.b.default)` }), + ); invalid.push( test({ @@ -371,7 +371,8 @@ const invalid = [].concat( parser, code: `import * as a from "./${folder}/a"; var {b:{c:{ e }}} = a`, errors: ["'e' not found in deeply imported namespace 'a.b.c'."], - })); + }), + ); }); ruleTester.run('namespace', rule, { valid, invalid }); diff --git a/tests/src/rules/newline-after-import.js b/tests/src/rules/newline-after-import.js index 6a8fb83e40..b78e891a35 100644 --- a/tests/src/rules/newline-after-import.js +++ b/tests/src/rules/newline-after-import.js @@ -8,6 +8,7 @@ import { getTSParsers, parsers, testVersion } from '../utils'; const IMPORT_ERROR_MESSAGE = 'Expected 1 empty line after import statement not followed by another import.'; const IMPORT_ERROR_MESSAGE_MULTIPLE = (count) => `Expected ${count} empty lines after import statement not followed by another import.`; const REQUIRE_ERROR_MESSAGE = 'Expected 1 empty line after require statement not followed by another require.'; +const REQUIRE_ERROR_MESSAGE_MULTIPLE = (count) => `Expected ${count} empty lines after require statement not followed by another require.`; const ruleTester = new RuleTester(); @@ -202,7 +203,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { options: [{ count: 4, exactCount: true }], }, { - code: `var foo = require('foo-module');\n\n\n\n// Some random comment\nvar foo = 'bar';`, + code: `var foo = require('foo-module');\n\n\n\n\n// Some random comment\nvar foo = 'bar';`, parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, options: [{ count: 4, exactCount: true, considerComments: true }], }, @@ -394,6 +395,19 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { `, parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, }, + { + code: `var foo = require('foo-module');\n\n\n// Some random comment\nvar foo = 'bar';`, + options: [{ count: 2, considerComments: true }], + }, + { + code: `var foo = require('foo-module');\n\n\n/**\n * Test comment\n */\nvar foo = 'bar';`, + options: [{ count: 2, considerComments: true }], + }, + { + code: `const foo = require('foo');\n\n\n// some random comment\nconst bar = function() {};`, + options: [{ count: 2, exactCount: true, considerComments: true }], + parserOptions: { ecmaVersion: 2015 }, + }, ), invalid: [].concat( @@ -825,7 +839,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { errors: [{ line: 1, column: 1, - message: 'Expected 2 empty lines after require statement not followed by another require.', + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015 }, }, @@ -836,7 +850,7 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { errors: [{ line: 1, column: 1, - message: 'Expected 2 empty lines after require statement not followed by another require.', + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015 }, }, @@ -852,14 +866,26 @@ ruleTester.run('newline-after-import', require('rules/newline-after-import'), { parserOptions: { ecmaVersion: 2015, considerComments: true, sourceType: 'module' }, }, { - code: `const foo = require('foo');\n\n\n// some random comment\nconst bar = function() {};`, - options: [{ count: 2, exactCount: true, considerComments: true }], + code: `var foo = require('foo-module');\nvar foo = require('foo-module');\n\n// Some random comment\nvar foo = 'bar';`, + output: `var foo = require('foo-module');\nvar foo = require('foo-module');\n\n\n// Some random comment\nvar foo = 'bar';`, + errors: [{ + line: 2, + column: 1, + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), + }], + parserOptions: { ecmaVersion: 2015, sourceType: 'module' }, + options: [{ considerComments: true, count: 2 }], + }, + { + code: `var foo = require('foo-module');\n\n/**\n * Test comment\n */\nvar foo = 'bar';`, + output: `var foo = require('foo-module');\n\n\n/**\n * Test comment\n */\nvar foo = 'bar';`, errors: [{ line: 1, column: 1, - message: 'Expected 2 empty lines after require statement not followed by another require.', + message: REQUIRE_ERROR_MESSAGE_MULTIPLE(2), }], parserOptions: { ecmaVersion: 2015 }, + options: [{ considerComments: true, count: 2 }], }, ), }); diff --git a/tests/src/rules/no-cycle.js b/tests/src/rules/no-cycle.js index d2adbf61f9..efc0fb6eb9 100644 --- a/tests/src/rules/no-cycle.js +++ b/tests/src/rules/no-cycle.js @@ -17,7 +17,7 @@ const testVersion = (specifier, t) => _testVersion(specifier, () => Object.assig const testDialects = ['es6']; -ruleTester.run('no-cycle', rule, { +const cases = { valid: [].concat( // this rule doesn't care if the cycle length is 0 test({ code: 'import foo from "./foo.js"' }), @@ -290,4 +290,30 @@ ruleTester.run('no-cycle', rule, { ], }), ), +}; + +ruleTester.run('no-cycle', rule, { + valid: flatMap(cases.valid, (testCase) => [ + testCase, + { + ...testCase, + code: `${testCase.code} // disableScc=true`, + options: [{ + ...testCase.options && testCase.options[0] || {}, + disableScc: true, + }], + }, + ]), + + invalid: flatMap(cases.invalid, (testCase) => [ + testCase, + { + ...testCase, + code: `${testCase.code} // disableScc=true`, + options: [{ + ...testCase.options && testCase.options[0] || {}, + disableScc: true, + }], + }, + ]), }); diff --git a/tests/src/rules/no-duplicates.js b/tests/src/rules/no-duplicates.js index f83221105a..c46f9df85d 100644 --- a/tests/src/rules/no-duplicates.js +++ b/tests/src/rules/no-duplicates.js @@ -455,28 +455,28 @@ import {x,y} from './foo' import { BULK_ACTIONS_ENABLED } from '../constants'; - + ${''} const TestComponent = () => { return
; } - + ${''} export default TestComponent; `, output: ` import { DEFAULT_FILTER_KEYS, BULK_DISABLED, - + ${''} BULK_ACTIONS_ENABLED } from '../constants'; import React from 'react'; - + ${''} const TestComponent = () => { return
; } - + ${''} export default TestComponent; `, errors: ["'../constants' imported multiple times.", "'../constants' imported multiple times."], @@ -704,6 +704,24 @@ context('TypeScript', function () { }, ], }), + test({ + code: "import type {x} from 'foo'; import {type y} from 'foo'", + ...parserConfig, + options: [{ 'prefer-inline': true }], + output: `import {type x,type y} from 'foo'; `, + errors: [ + { + line: 1, + column: 22, + message: "'foo' imported multiple times.", + }, + { + line: 1, + column: 50, + message: "'foo' imported multiple times.", + }, + ], + }), test({ code: "import {type x} from 'foo'; import type {y} from 'foo'", ...parserConfig, diff --git a/tests/src/rules/no-extraneous-dependencies.js b/tests/src/rules/no-extraneous-dependencies.js index cb0398ada2..4b221de353 100644 --- a/tests/src/rules/no-extraneous-dependencies.js +++ b/tests/src/rules/no-extraneous-dependencies.js @@ -26,6 +26,7 @@ const packageDirWithEmpty = path.join(__dirname, '../../files/empty'); const packageDirBundleDeps = path.join(__dirname, '../../files/bundled-dependencies/as-array-bundle-deps'); const packageDirBundledDepsAsObject = path.join(__dirname, '../../files/bundled-dependencies/as-object'); const packageDirBundledDepsRaceCondition = path.join(__dirname, '../../files/bundled-dependencies/race-condition'); +const emptyPackageDir = path.join(__dirname, '../../files/empty-folder'); const { dependencies: deps, @@ -104,6 +105,14 @@ ruleTester.run('no-extraneous-dependencies', rule, { code: 'import leftpad from "left-pad";', options: [{ packageDir: packageDirMonoRepoRoot }], }), + test({ + code: 'import leftpad from "left-pad";', + options: [{ packageDir: [emptyPackageDir, packageDirMonoRepoRoot] }], + }), + test({ + code: 'import leftpad from "left-pad";', + options: [{ packageDir: [packageDirMonoRepoRoot, emptyPackageDir] }], + }), test({ code: 'import react from "react";', options: [{ packageDir: [packageDirMonoRepoRoot, packageDirMonoRepoWithNested] }], diff --git a/tests/src/rules/no-import-module-exports.js b/tests/src/rules/no-import-module-exports.js index c2bf7ed132..aa927857e0 100644 --- a/tests/src/rules/no-import-module-exports.js +++ b/tests/src/rules/no-import-module-exports.js @@ -74,13 +74,13 @@ ruleTester.run('no-import-module-exports', rule, { import fs from 'fs/promises'; const subscriptions = new Map(); - + ${''} export default async (client) => { /** * loads all modules and their subscriptions */ const modules = await fs.readdir('./src/modules'); - + ${''} await Promise.all( modules.map(async (moduleName) => { // Loads the module @@ -97,7 +97,7 @@ ruleTester.run('no-import-module-exports', rule, { } }) ); - + ${''} /** * Setting up all events. * binds all events inside the subscriptions map to call all functions provided diff --git a/tests/src/rules/no-unused-modules.js b/tests/src/rules/no-unused-modules.js index b09d5d759c..80bd70227e 100644 --- a/tests/src/rules/no-unused-modules.js +++ b/tests/src/rules/no-unused-modules.js @@ -38,6 +38,13 @@ const unusedExportsTypescriptOptions = [{ ignoreExports: undefined, }]; +const unusedExportsTypescriptIgnoreUnusedTypesOptions = [{ + unusedExports: true, + ignoreUnusedTypeExports: true, + src: [testFilePath('./no-unused-modules/typescript')], + ignoreExports: undefined, +}]; + const unusedExportsJsxOptions = [{ unusedExports: true, src: [testFilePath('./no-unused-modules/jsx')], @@ -1209,6 +1216,66 @@ context('TypeScript', function () { }); }); +describe('ignoreUnusedTypeExports', () => { + getTSParsers().forEach((parser) => { + typescriptRuleTester.run('no-unused-modules', rule, { + valid: [ + // unused vars should not report + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export interface c {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-c-unused.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export type d = {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-d-unused.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export enum e { f };`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-e-unused.ts', + ), + }), + // used vars should not report + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export interface c {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-c-used-as-type.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export type d = {};`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-d-used-as-type.ts', + ), + }), + test({ + options: unusedExportsTypescriptIgnoreUnusedTypesOptions, + code: `export enum e { f };`, + parser, + filename: testFilePath( + './no-unused-modules/typescript/file-ts-e-used-as-type.ts', + ), + }), + ], + invalid: [], + }); + }); +}); + describe('correctly work with JSX only files', () => { jsxRuleTester.run('no-unused-modules', rule, { valid: [ diff --git a/tests/src/rules/order.js b/tests/src/rules/order.js index a6a8735a6f..c2d659f839 100644 --- a/tests/src/rules/order.js +++ b/tests/src/rules/order.js @@ -169,6 +169,34 @@ ruleTester.run('order', rule, { ['sibling', 'parent', 'external'], ] }], }), + // Grouping import types and alphabetize + test({ + code: ` + import async from 'async'; + import fs from 'fs'; + import path from 'path'; + + import index from '.'; + import relParent3 from '../'; + import relParent1 from '../foo'; + import sibling from './foo'; + `, + options: [{ groups: [ + ['builtin', 'external'], + ], alphabetize: { order: 'asc', caseInsensitive: true } }], + }), + test({ + code: ` + import { fooz } from '../baz.js' + import { foo } from './bar.js' + `, + options: [{ + alphabetize: { order: 'asc', caseInsensitive: true }, + groups: ['builtin', 'external', 'internal', ['parent', 'sibling', 'index'], 'object'], + 'newlines-between': 'always', + warnOnUnassignedImports: true, + }], + }), // Omitted types should implicitly be considered as the last type test({ code: ` diff --git a/tests/src/scc.js b/tests/src/scc.js new file mode 100644 index 0000000000..376b783ce1 --- /dev/null +++ b/tests/src/scc.js @@ -0,0 +1,179 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import StronglyConnectedComponentsBuilder from '../../src/scc'; +import ExportMapBuilder from '../../src/exportMap/builder'; + +function exportMapFixtureBuilder(path, imports, isOnlyImportingTypes = false) { + return { + path, + imports: new Map(imports.map((imp) => [imp.path, { getter: () => imp, declarations: [{ isOnlyImportingTypes }] }])), + }; +} + +describe('Strongly Connected Components Builder', () => { + afterEach(() => ExportMapBuilder.for.restore()); + afterEach(() => StronglyConnectedComponentsBuilder.clearCache()); + + describe('When getting an SCC', () => { + const source = ''; + const context = { + settings: {}, + parserOptions: {}, + parserPath: '', + }; + + describe('Given two files', () => { + describe('When they don\'t value-cycle', () => { + it('Should return foreign SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [exportMapFixtureBuilder('bar.js', [])]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0 }); + }); + }); + + describe('When they do value-cycle', () => { + it('Should return same SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', [exportMapFixtureBuilder('bar.js', [])]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0 }); + }); + }); + + describe('When they type-cycle', () => { + it('Should return foreign SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', []), + ], true), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0 }); + }); + }); + }); + + describe('Given three files', () => { + describe('When they form a line', () => { + describe('When A -> B -> C', () => { + it('Should return foreign SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', []), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 2, 'bar.js': 1, 'buzz.js': 0 }); + }); + }); + + describe('When A -> B <-> C', () => { + it('Should return 2 SCCs, A on its own', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('bar.js', []), + ]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + + describe('When A <-> B -> C', () => { + it('Should return 2 SCCs, C on its own', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', []), + exportMapFixtureBuilder('foo.js', []), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 1, 'bar.js': 1, 'buzz.js': 0 }); + }); + }); + + describe('When A <-> B <-> C', () => { + it('Should return same SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', []), + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('bar.js', []), + ]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + }); + + describe('When they form a loop', () => { + it('Should return same SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('foo.js', []), + ]), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + + describe('When they form a Y', () => { + it('Should return 3 distinct SCCs', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', []), + exportMapFixtureBuilder('buzz.js', []), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 2, 'bar.js': 0, 'buzz.js': 1 }); + }); + }); + + describe('When they form a Mercedes', () => { + it('Should return 1 SCC', () => { + sinon.stub(ExportMapBuilder, 'for').returns( + exportMapFixtureBuilder('foo.js', [ + exportMapFixtureBuilder('bar.js', [ + exportMapFixtureBuilder('foo.js', []), + exportMapFixtureBuilder('buzz.js', []), + ]), + exportMapFixtureBuilder('buzz.js', [ + exportMapFixtureBuilder('foo.js', []), + exportMapFixtureBuilder('bar.js', []), + ]), + ]), + ); + const actual = StronglyConnectedComponentsBuilder.for(source, context); + expect(actual).to.deep.equal({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }); + }); + }); + }); + }); +}); diff --git a/tests/src/utils.js b/tests/src/utils.js index d5215b02e3..24d5504a71 100644 --- a/tests/src/utils.js +++ b/tests/src/utils.js @@ -42,10 +42,6 @@ export function eslintVersionSatisfies(specifier) { return semver.satisfies(eslintPkg.version, specifier); } -export function testVersion(specifier, t) { - return eslintVersionSatisfies(specifier) ? test(t()) : []; -} - export function test(t) { if (arguments.length !== 1) { throw new SyntaxError('`test` requires exactly one object argument'); @@ -61,6 +57,10 @@ export function test(t) { }; } +export function testVersion(specifier, t) { + return eslintVersionSatisfies(specifier) ? test(t()) : []; +} + export function testContext(settings) { return { getFilename() { return FILENAME; }, settings: settings || {} }; diff --git a/utils/.attw.json b/utils/.attw.json new file mode 100644 index 0000000000..45dd01e12f --- /dev/null +++ b/utils/.attw.json @@ -0,0 +1,5 @@ +{ + "ignoreRules": [ + "cjs-only-exports-default" + ] +} diff --git a/utils/.npmignore b/utils/.npmignore new file mode 100644 index 0000000000..366f3ebb6e --- /dev/null +++ b/utils/.npmignore @@ -0,0 +1 @@ +.attw.json diff --git a/utils/CHANGELOG.md b/utils/CHANGELOG.md index ae3588a390..27102bc73a 100644 --- a/utils/CHANGELOG.md +++ b/utils/CHANGELOG.md @@ -5,6 +5,30 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +## v2.9.0 - 2024-09-02 + +### New +- add support for Flat Config ([#3018], thanks [@michaelfaith]) + +## v2.8.2 - 2024-08-25 + +### Fixed +- `parse`: also delete `parserOptions.projectService` ([#3039], thanks [@Mysak0CZ]) + +### Changed +- [types] use shared config (thanks [@ljharb]) +- [meta] add `exports`, `main` +- [meta] add `repository.directory` field +- [refactor] avoid hoisting + +## v2.8.1 - 2024-02-26 + +### Fixed +- `parse`: also delete `parserOptions.EXPERIMENTAL_useProjectService` ([#2963], thanks [@JoshuaKGoldberg]) + +### Changed +- add types (thanks [@ljharb]) + ## v2.8.0 - 2023-04-14 ### New @@ -131,6 +155,9 @@ Yanked due to critical issue with cache key resulting from #839. ### Fixed - `unambiguous.test()` regex is now properly in multiline mode +[#3039]: https://github.com/import-js/eslint-plugin-import/pull/3039 +[#3018]: https://github.com/import-js/eslint-plugin-import/pull/3018 +[#2963]: https://github.com/import-js/eslint-plugin-import/pull/2963 [#2755]: https://github.com/import-js/eslint-plugin-import/pull/2755 [#2714]: https://github.com/import-js/eslint-plugin-import/pull/2714 [#2523]: https://github.com/import-js/eslint-plugin-import/pull/2523 @@ -169,12 +196,15 @@ Yanked due to critical issue with cache key resulting from #839. [@hulkish]: https://github.com/hulkish [@Hypnosphi]: https://github.com/Hypnosphi [@iamnapo]: https://github.com/iamnapo +[@JoshuaKGoldberg]: https://github.com/JoshuaKGoldberg [@JounQin]: https://github.com/JounQin [@kaiyoma]: https://github.com/kaiyoma [@leipert]: https://github.com/leipert [@manuth]: https://github.com/manuth [@maxkomarychev]: https://github.com/maxkomarychev [@mgwalker]: https://github.com/mgwalker +[@michaelfaith]: https://github.com/michaelfaith +[@Mysak0CZ]: https://github.com/Mysak0CZ [@nicolo-ribaudo]: https://github.com/nicolo-ribaudo [@pmcelhaney]: https://github.com/pmcelhaney [@sergei-startsev]: https://github.com/sergei-startsev diff --git a/utils/ModuleCache.d.ts b/utils/ModuleCache.d.ts new file mode 100644 index 0000000000..72a72a0699 --- /dev/null +++ b/utils/ModuleCache.d.ts @@ -0,0 +1,22 @@ +import type { ESLintSettings } from "./types"; + +export type CacheKey = unknown; +export type CacheObject = { + result: unknown; + lastSeen: ReturnType; +}; + +declare class ModuleCache { + map: Map; + + constructor(map?: Map); + + get(cacheKey: CacheKey, settings: ESLintSettings): T | undefined; + + set(cacheKey: CacheKey, result: T): T; + + static getSettings(settings: ESLintSettings): { lifetime: number } & Omit; +} +export default ModuleCache; + +export type { ModuleCache } diff --git a/utils/ModuleCache.js b/utils/ModuleCache.js index 4b1edc0eff..24c76849dd 100644 --- a/utils/ModuleCache.js +++ b/utils/ModuleCache.js @@ -4,26 +4,26 @@ exports.__esModule = true; const log = require('debug')('eslint-module-utils:ModuleCache'); +/** @type {import('./ModuleCache').ModuleCache} */ class ModuleCache { + /** @param {typeof import('./ModuleCache').ModuleCache.prototype.map} map */ constructor(map) { - this.map = map || new Map(); + this.map = map || /** @type {{typeof import('./ModuleCache').ModuleCache.prototype.map} */ new Map(); } - /** - * returns value for returning inline - * @param {[type]} cacheKey [description] - * @param {[type]} result [description] - */ + /** @type {typeof import('./ModuleCache').ModuleCache.prototype.set} */ set(cacheKey, result) { this.map.set(cacheKey, { result, lastSeen: process.hrtime() }); log('setting entry for', cacheKey); return result; } + /** @type {typeof import('./ModuleCache').ModuleCache.prototype.get} */ get(cacheKey, settings) { if (this.map.has(cacheKey)) { const f = this.map.get(cacheKey); // check freshness + // @ts-expect-error TS can't narrow properly from `has` and `get` if (process.hrtime(f.lastSeen)[0] < settings.lifetime) { return f.result; } } else { log('cache miss for', cacheKey); @@ -32,19 +32,21 @@ class ModuleCache { return undefined; } -} - -ModuleCache.getSettings = function (settings) { - const cacheSettings = Object.assign({ - lifetime: 30, // seconds - }, settings['import/cache']); + /** @type {typeof import('./ModuleCache').ModuleCache.getSettings} */ + static getSettings(settings) { + /** @type {ReturnType} */ + const cacheSettings = Object.assign({ + lifetime: 30, // seconds + }, settings['import/cache']); + + // parse infinity + // @ts-expect-error the lack of type overlap is because we're abusing `cacheSettings` as a temporary object + if (cacheSettings.lifetime === '∞' || cacheSettings.lifetime === 'Infinity') { + cacheSettings.lifetime = Infinity; + } - // parse infinity - if (cacheSettings.lifetime === '∞' || cacheSettings.lifetime === 'Infinity') { - cacheSettings.lifetime = Infinity; + return cacheSettings; } - - return cacheSettings; -}; +} exports.default = ModuleCache; diff --git a/utils/declaredScope.d.ts b/utils/declaredScope.d.ts new file mode 100644 index 0000000000..e37200d870 --- /dev/null +++ b/utils/declaredScope.d.ts @@ -0,0 +1,8 @@ +import { Rule, Scope } from 'eslint'; + +declare function declaredScope( + context: Rule.RuleContext, + name: string +): Scope.Scope['type'] | undefined; + +export default declaredScope; diff --git a/utils/declaredScope.js b/utils/declaredScope.js index dd2a20149f..0f0a3d9458 100644 --- a/utils/declaredScope.js +++ b/utils/declaredScope.js @@ -2,9 +2,10 @@ exports.__esModule = true; +/** @type {import('./declaredScope').default} */ exports.default = function declaredScope(context, name) { const references = context.getScope().references; const reference = references.find((x) => x.identifier.name === name); - if (!reference) { return undefined; } + if (!reference || !reference.resolved) { return undefined; } return reference.resolved.scope.type; }; diff --git a/utils/hash.d.ts b/utils/hash.d.ts new file mode 100644 index 0000000000..5e4cf471bd --- /dev/null +++ b/utils/hash.d.ts @@ -0,0 +1,14 @@ +import type { Hash } from 'crypto'; + +declare function hashArray(value: Array, hash?: Hash): Hash; + +declare function hashObject(value: T, hash?: Hash): Hash; + +declare function hashify( + value: Array | object | unknown, + hash?: Hash, +): Hash; + +export default hashify; + +export { hashArray, hashObject }; diff --git a/utils/hash.js b/utils/hash.js index b9bff25bd9..b3ce618b54 100644 --- a/utils/hash.js +++ b/utils/hash.js @@ -11,6 +11,7 @@ const createHash = require('crypto').createHash; const stringify = JSON.stringify; +/** @type {import('./hash').default} */ function hashify(value, hash) { if (!hash) { hash = createHash('sha256'); } @@ -26,6 +27,7 @@ function hashify(value, hash) { } exports.default = hashify; +/** @type {import('./hash').hashArray} */ function hashArray(array, hash) { if (!hash) { hash = createHash('sha256'); } @@ -41,13 +43,15 @@ function hashArray(array, hash) { hashify.array = hashArray; exports.hashArray = hashArray; -function hashObject(object, hash) { - if (!hash) { hash = createHash('sha256'); } +/** @type {import('./hash').hashObject} */ +function hashObject(object, optionalHash) { + const hash = optionalHash || createHash('sha256'); hash.update('{'); Object.keys(object).sort().forEach((key) => { hash.update(stringify(key)); hash.update(':'); + // @ts-expect-error the key is guaranteed to exist on the object here hashify(object[key], hash); hash.update(','); }); diff --git a/utils/ignore.d.ts b/utils/ignore.d.ts new file mode 100644 index 0000000000..53953b33e9 --- /dev/null +++ b/utils/ignore.d.ts @@ -0,0 +1,12 @@ +import { Rule } from 'eslint'; +import type { ESLintSettings, Extension } from './types'; + +declare function ignore(path: string, context: Rule.RuleContext): boolean; + +declare function getFileExtensions(settings: ESLintSettings): Set; + +declare function hasValidExtension(path: string, context: Rule.RuleContext): path is `${string}${Extension}`; + +export default ignore; + +export { getFileExtensions, hasValidExtension } diff --git a/utils/ignore.js b/utils/ignore.js index 960538e706..a42d4ceb1f 100644 --- a/utils/ignore.js +++ b/utils/ignore.js @@ -7,20 +7,14 @@ const extname = require('path').extname; const log = require('debug')('eslint-plugin-import:utils:ignore'); // one-shot memoized -let cachedSet; let lastSettings; -function validExtensions(context) { - if (cachedSet && context.settings === lastSettings) { - return cachedSet; - } - - lastSettings = context.settings; - cachedSet = makeValidExtensionSet(context.settings); - return cachedSet; -} +/** @type {Set} */ let cachedSet; +/** @type {import('./types').ESLintSettings} */ let lastSettings; +/** @type {import('./ignore').getFileExtensions} */ function makeValidExtensionSet(settings) { // start with explicit JS-parsed extensions - const exts = new Set(settings['import/extensions'] || ['.js']); + /** @type {Set} */ + const exts = new Set(settings['import/extensions'] || ['.js', '.mjs', '.cjs']); // all alternate parser extensions are also valid if ('import/parsers' in settings) { @@ -37,11 +31,34 @@ function makeValidExtensionSet(settings) { } exports.getFileExtensions = makeValidExtensionSet; +/** @type {(context: import('eslint').Rule.RuleContext) => Set} */ +function validExtensions(context) { + if (cachedSet && context.settings === lastSettings) { + return cachedSet; + } + + lastSettings = context.settings; + cachedSet = makeValidExtensionSet(context.settings); + return cachedSet; +} + +/** @type {import('./ignore').hasValidExtension} */ +function hasValidExtension(path, context) { + // eslint-disable-next-line no-extra-parens + return validExtensions(context).has(/** @type {import('./types').Extension} */ (extname(path))); +} +exports.hasValidExtension = hasValidExtension; + +/** @type {import('./ignore').default} */ exports.default = function ignore(path, context) { // check extension whitelist first (cheap) - if (!hasValidExtension(path, context)) { return true; } + if (!hasValidExtension(path, context)) { + return true; + } - if (!('import/ignore' in context.settings)) { return false; } + if (!('import/ignore' in context.settings)) { + return false; + } const ignoreStrings = context.settings['import/ignore']; for (let i = 0; i < ignoreStrings.length; i++) { @@ -54,8 +71,3 @@ exports.default = function ignore(path, context) { return false; }; - -function hasValidExtension(path, context) { - return validExtensions(context).has(extname(path)); -} -exports.hasValidExtension = hasValidExtension; diff --git a/utils/module-require.d.ts b/utils/module-require.d.ts new file mode 100644 index 0000000000..91df90d616 --- /dev/null +++ b/utils/module-require.d.ts @@ -0,0 +1,3 @@ +declare function moduleRequire(p: string): T; + +export default moduleRequire; diff --git a/utils/module-require.js b/utils/module-require.js index 96ef82ba51..14006c5dc6 100644 --- a/utils/module-require.js +++ b/utils/module-require.js @@ -6,23 +6,28 @@ const Module = require('module'); const path = require('path'); // borrowed from babel-eslint +/** @type {(filename: string) => Module} */ function createModule(filename) { const mod = new Module(filename); mod.filename = filename; + // @ts-expect-error _nodeModulesPaths are undocumented mod.paths = Module._nodeModulePaths(path.dirname(filename)); return mod; } +/** @type {import('./module-require').default} */ exports.default = function moduleRequire(p) { try { // attempt to get espree relative to eslint const eslintPath = require.resolve('eslint'); const eslintModule = createModule(eslintPath); + // @ts-expect-error _resolveFilename is undocumented return require(Module._resolveFilename(p, eslintModule)); } catch (err) { /* ignore */ } try { // try relative to entry point + // @ts-expect-error TODO: figure out what this is return require.main.require(p); } catch (err) { /* ignore */ } diff --git a/utils/moduleVisitor.d.ts b/utils/moduleVisitor.d.ts new file mode 100644 index 0000000000..6f30186d71 --- /dev/null +++ b/utils/moduleVisitor.d.ts @@ -0,0 +1,26 @@ +import type { Rule } from 'eslint'; +import type { Node } from 'estree'; + +type Visitor = (source: Node, importer: unknown) => any; + +type Options = { + amd?: boolean; + commonjs?: boolean; + esmodule?: boolean; + ignore?: string[]; +}; + +declare function moduleVisitor( + visitor: Visitor, + options?: Options, +): object; + +export default moduleVisitor; + +export type Schema = NonNullable; + +declare function makeOptionsSchema(additionalProperties?: Partial): Schema + +declare const optionsSchema: Schema; + +export { makeOptionsSchema, optionsSchema }; diff --git a/utils/moduleVisitor.js b/utils/moduleVisitor.js index c312ca2d45..acdee6774f 100644 --- a/utils/moduleVisitor.js +++ b/utils/moduleVisitor.js @@ -2,50 +2,58 @@ exports.__esModule = true; +/** @typedef {import('estree').Node} Node */ +/** @typedef {{ arguments: import('estree').CallExpression['arguments'], callee: Node }} Call */ +/** @typedef {import('estree').ImportDeclaration | import('estree').ExportNamedDeclaration | import('estree').ExportAllDeclaration} Declaration */ + /** * Returns an object of node visitors that will call * 'visitor' with every discovered module path. * - * todo: correct function prototype for visitor - * @param {Function(String)} visitor [description] - * @param {[type]} options [description] - * @return {object} + * @type {(import('./moduleVisitor').default)} */ exports.default = function visitModules(visitor, options) { + const ignore = options && options.ignore; + const amd = !!(options && options.amd); + const commonjs = !!(options && options.commonjs); // if esmodule is not explicitly disabled, it is assumed to be enabled - options = Object.assign({ esmodule: true }, options); + const esmodule = !!Object.assign({ esmodule: true }, options).esmodule; - let ignoreRegExps = []; - if (options.ignore != null) { - ignoreRegExps = options.ignore.map((p) => new RegExp(p)); - } + const ignoreRegExps = ignore == null ? [] : ignore.map((p) => new RegExp(p)); + /** @type {(source: undefined | null | import('estree').Literal, importer: Parameters[1]) => void} */ function checkSourceValue(source, importer) { if (source == null) { return; } //? // handle ignore - if (ignoreRegExps.some((re) => re.test(source.value))) { return; } + if (ignoreRegExps.some((re) => re.test(String(source.value)))) { return; } // fire visitor visitor(source, importer); } // for import-y declarations + /** @type {(node: Declaration) => void} */ function checkSource(node) { checkSourceValue(node.source, node); } // for esmodule dynamic `import()` calls + /** @type {(node: import('estree').ImportExpression | import('estree').CallExpression) => void} */ function checkImportCall(node) { + /** @type {import('estree').Expression | import('estree').Literal | import('estree').CallExpression['arguments'][0]} */ let modulePath; // refs https://github.com/estree/estree/blob/HEAD/es2020.md#importexpression if (node.type === 'ImportExpression') { modulePath = node.source; } else if (node.type === 'CallExpression') { + // @ts-expect-error this structure is from an older version of eslint if (node.callee.type !== 'Import') { return; } if (node.arguments.length !== 1) { return; } modulePath = node.arguments[0]; + } else { + throw new TypeError('this should be unreachable'); } if (modulePath.type !== 'Literal') { return; } @@ -56,6 +64,7 @@ exports.default = function visitModules(visitor, options) { // for CommonJS `require` calls // adapted from @mctep: https://git.io/v4rAu + /** @type {(call: Call) => void} */ function checkCommon(call) { if (call.callee.type !== 'Identifier') { return; } if (call.callee.name !== 'require') { return; } @@ -68,6 +77,7 @@ exports.default = function visitModules(visitor, options) { checkSourceValue(modulePath, call); } + /** @type {(call: Call) => void} */ function checkAMD(call) { if (call.callee.type !== 'Identifier') { return; } if (call.callee.name !== 'require' && call.callee.name !== 'define') { return; } @@ -77,6 +87,7 @@ exports.default = function visitModules(visitor, options) { if (modules.type !== 'ArrayExpression') { return; } for (const element of modules.elements) { + if (!element) { continue; } if (element.type !== 'Literal') { continue; } if (typeof element.value !== 'string') { continue; } @@ -92,7 +103,7 @@ exports.default = function visitModules(visitor, options) { } const visitors = {}; - if (options.esmodule) { + if (esmodule) { Object.assign(visitors, { ImportDeclaration: checkSource, ExportNamedDeclaration: checkSource, @@ -102,12 +113,12 @@ exports.default = function visitModules(visitor, options) { }); } - if (options.commonjs || options.amd) { + if (commonjs || amd) { const currentCallExpression = visitors.CallExpression; - visitors.CallExpression = function (call) { + visitors.CallExpression = /** @type {(call: Call) => void} */ function (call) { if (currentCallExpression) { currentCallExpression(call); } - if (options.commonjs) { checkCommon(call); } - if (options.amd) { checkAMD(call); } + if (commonjs) { checkCommon(call); } + if (amd) { checkAMD(call); } }; } @@ -115,10 +126,11 @@ exports.default = function visitModules(visitor, options) { }; /** - * make an options schema for the module visitor, optionally - * adding extra fields. + * make an options schema for the module visitor, optionally adding extra fields. + * @type {import('./moduleVisitor').makeOptionsSchema} */ function makeOptionsSchema(additionalProperties) { + /** @type {import('./moduleVisitor').Schema} */ const base = { type: 'object', properties: { @@ -137,6 +149,7 @@ function makeOptionsSchema(additionalProperties) { if (additionalProperties) { for (const key in additionalProperties) { + // @ts-expect-error TS always has trouble with arbitrary object assignment/mutation base.properties[key] = additionalProperties[key]; } } @@ -146,8 +159,6 @@ function makeOptionsSchema(additionalProperties) { exports.makeOptionsSchema = makeOptionsSchema; /** - * json schema object for options parameter. can be used to build - * rule options schema object. - * @type {Object} + * json schema object for options parameter. can be used to build rule options schema object. */ exports.optionsSchema = makeOptionsSchema(); diff --git a/utils/package.json b/utils/package.json index d56c442b1a..fe3541ada3 100644 --- a/utils/package.json +++ b/utils/package.json @@ -1,17 +1,50 @@ { "name": "eslint-module-utils", - "version": "2.8.0", + "version": "2.9.0", "description": "Core utilities to support eslint-plugin-import and other module-related plugins.", "engines": { "node": ">=4" }, + "main": false, + "exports": { + "./ModuleCache": "./ModuleCache.js", + "./ModuleCache.js": "./ModuleCache.js", + "./declaredScope": "./declaredScope.js", + "./declaredScope.js": "./declaredScope.js", + "./hash": "./hash.js", + "./hash.js": "./hash.js", + "./ignore": "./ignore.js", + "./ignore.js": "./ignore.js", + "./module-require": "./module-require.js", + "./module-require.js": "./module-require.js", + "./moduleVisitor": "./moduleVisitor.js", + "./moduleVisitor.js": "./moduleVisitor.js", + "./parse": "./parse.js", + "./parse.js": "./parse.js", + "./pkgDir": "./pkgDir.js", + "./pkgDir.js": "./pkgDir.js", + "./pkgUp": "./pkgUp.js", + "./pkgUp.js": "./pkgUp.js", + "./readPkgUp": "./readPkgUp.js", + "./readPkgUp.js": "./readPkgUp.js", + "./resolve": "./resolve.js", + "./resolve.js": "./resolve.js", + "./unambiguous": "./unambiguous.js", + "./unambiguous.js": "./unambiguous.js", + "./visit": "./visit.js", + "./visit.js": "./visit.js", + "./package.json": "./package.json" + }, "scripts": { "prepublishOnly": "cp ../{LICENSE,.npmrc} ./", + "tsc": "tsc -p .", + "posttsc": "attw -P .", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "git+https://github.com/import-js/eslint-plugin-import.git" + "url": "git+https://github.com/import-js/eslint-plugin-import.git", + "directory": "utils" }, "keywords": [ "eslint-plugin-import", @@ -28,9 +61,22 @@ "dependencies": { "debug": "^3.2.7" }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.15.4", + "@ljharb/tsconfig": "^0.2.0", + "@types/debug": "^4.1.12", + "@types/eslint": "^8.56.3", + "@types/node": "^20.11.20", + "typescript": "next" + }, "peerDependenciesMeta": { "eslint": { "optional": true } + }, + "publishConfig": { + "ignore": [ + ".attw.json" + ] } } diff --git a/utils/parse.d.ts b/utils/parse.d.ts new file mode 100644 index 0000000000..f92ab3edc6 --- /dev/null +++ b/utils/parse.d.ts @@ -0,0 +1,11 @@ +import { AST, Rule } from 'eslint'; + + + +declare function parse( + path: string, + content: string, + context: Rule.RuleContext +): AST.Program | null | undefined; + +export default parse; diff --git a/utils/parse.js b/utils/parse.js index 7646b3177c..75d527b008 100644 --- a/utils/parse.js +++ b/utils/parse.js @@ -2,12 +2,16 @@ exports.__esModule = true; +/** @typedef {`.${string}`} Extension */ +/** @typedef {NonNullable & { 'import/extensions'?: Extension[], 'import/parsers'?: { [k: string]: Extension[] }, 'import/cache'?: { lifetime: number | '∞' | 'Infinity' } }} ESLintSettings */ + const moduleRequire = require('./module-require').default; const extname = require('path').extname; const fs = require('fs'); const log = require('debug')('eslint-plugin-import:parse'); +/** @type {(parserPath: NonNullable) => unknown} */ function getBabelEslintVisitorKeys(parserPath) { if (parserPath.endsWith('index.js')) { const hypotheticalLocation = parserPath.replace('index.js', 'visitor-keys.js'); @@ -19,6 +23,7 @@ function getBabelEslintVisitorKeys(parserPath) { return null; } +/** @type {(parserPath: import('eslint').Rule.RuleContext['parserPath'], parserInstance: { VisitorKeys: unknown }, parsedResult?: { visitorKeys?: unknown }) => unknown} */ function keysFromParser(parserPath, parserInstance, parsedResult) { // Exposed by @typescript-eslint/parser and @babel/eslint-parser if (parsedResult && parsedResult.visitorKeys) { @@ -35,22 +40,69 @@ function keysFromParser(parserPath, parserInstance, parsedResult) { // this exists to smooth over the unintentional breaking change in v2.7. // TODO, semver-major: avoid mutating `ast` and return a plain object instead. +/** @type {(ast: T, visitorKeys: unknown) => T} */ function makeParseReturn(ast, visitorKeys) { if (ast) { + // @ts-expect-error see TODO ast.visitorKeys = visitorKeys; + // @ts-expect-error see TODO ast.ast = ast; } return ast; } +/** @type {(text: string) => string} */ function stripUnicodeBOM(text) { return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; } +/** @type {(text: string) => string} */ function transformHashbang(text) { return text.replace(/^#!([^\r\n]+)/u, (_, captured) => `//${captured}`); } +/** @type {(path: string, context: import('eslint').Rule.RuleContext & { settings?: ESLintSettings }) => import('eslint').Rule.RuleContext['parserPath']} */ +function getParserPath(path, context) { + const parsers = context.settings['import/parsers']; + if (parsers != null) { + // eslint-disable-next-line no-extra-parens + const extension = /** @type {Extension} */ (extname(path)); + for (const parserPath in parsers) { + if (parsers[parserPath].indexOf(extension) > -1) { + // use this alternate parser + log('using alt parser:', parserPath); + return parserPath; + } + } + } + // default to use ESLint parser + return context.parserPath; +} + +/** @type {(path: string, context: import('eslint').Rule.RuleContext) => string | null | (import('eslint').Linter.ParserModule)} */ +function getParser(path, context) { + const parserPath = getParserPath(path, context); + if (parserPath) { + return parserPath; + } + if ( + !!context.languageOptions + && !!context.languageOptions.parser + && typeof context.languageOptions.parser !== 'string' + && ( + // @ts-expect-error TODO: figure out a better type + typeof context.languageOptions.parser.parse === 'function' + // @ts-expect-error TODO: figure out a better type + || typeof context.languageOptions.parser.parseForESLint === 'function' + ) + ) { + return context.languageOptions.parser; + } + + return null; +} + +/** @type {import('./parse').default} */ exports.default = function parse(path, content, context) { if (context == null) { throw new Error('need context to parse properly'); } @@ -81,6 +133,8 @@ exports.default = function parse(path, content, context) { // "project" or "projects" in parserOptions. Removing these options means the parser will // only parse one file in isolate mode, which is much, much faster. // https://github.com/import-js/eslint-plugin-import/issues/1408#issuecomment-509298962 + delete parserOptions.EXPERIMENTAL_useProjectService; + delete parserOptions.projectService; delete parserOptions.project; delete parserOptions.projects; @@ -96,10 +150,12 @@ exports.default = function parse(path, content, context) { try { const parserRaw = parser.parseForESLint(content, parserOptions); ast = parserRaw.ast; + // @ts-expect-error TODO: FIXME return makeParseReturn(ast, keysFromParser(parserOrPath, parser, parserRaw)); } catch (e) { console.warn(); console.warn('Error while parsing ' + parserOptions.filePath); + // @ts-expect-error e is almost certainly an Error here console.warn('Line ' + e.lineNumber + ', column ' + e.column + ': ' + e.message); } if (!ast || typeof ast !== 'object') { @@ -108,42 +164,12 @@ exports.default = function parse(path, content, context) { '`parseForESLint` from parser `' + (typeof parserOrPath === 'string' ? parserOrPath : '`context.languageOptions.parser`') + '` is invalid and will just be ignored' ); } else { + // @ts-expect-error TODO: FIXME return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined)); } } const ast = parser.parse(content, parserOptions); + // @ts-expect-error TODO: FIXME return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined)); }; - -function getParser(path, context) { - const parserPath = getParserPath(path, context); - if (parserPath) { - return parserPath; - } - const isFlat = context.languageOptions - && context.languageOptions.parser - && typeof context.languageOptions.parser !== 'string' - && ( - typeof context.languageOptions.parser.parse === 'function' - || typeof context.languageOptions.parser.parseForESLint === 'function' - ); - - return isFlat ? context.languageOptions.parser : null; -} - -function getParserPath(path, context) { - const parsers = context.settings['import/parsers']; - if (parsers != null) { - const extension = extname(path); - for (const parserPath in parsers) { - if (parsers[parserPath].indexOf(extension) > -1) { - // use this alternate parser - log('using alt parser:', parserPath); - return parserPath; - } - } - } - // default to use ESLint parser - return context.parserPath; -} diff --git a/utils/pkgDir.d.ts b/utils/pkgDir.d.ts new file mode 100644 index 0000000000..af01e2e9bf --- /dev/null +++ b/utils/pkgDir.d.ts @@ -0,0 +1,3 @@ +declare function pkgDir(cwd: string): string | null; + +export default pkgDir; diff --git a/utils/pkgDir.js b/utils/pkgDir.js index 34412202f1..84c334680a 100644 --- a/utils/pkgDir.js +++ b/utils/pkgDir.js @@ -5,6 +5,7 @@ const pkgUp = require('./pkgUp').default; exports.__esModule = true; +/** @type {import('./pkgDir').default} */ exports.default = function (cwd) { const fp = pkgUp({ cwd }); return fp ? path.dirname(fp) : null; diff --git a/utils/pkgUp.d.ts b/utils/pkgUp.d.ts new file mode 100644 index 0000000000..6382457bec --- /dev/null +++ b/utils/pkgUp.d.ts @@ -0,0 +1,3 @@ +declare function pkgUp(opts?: { cwd?: string }): string | null; + +export default pkgUp; diff --git a/utils/pkgUp.js b/utils/pkgUp.js index 889f62265f..076e59fd76 100644 --- a/utils/pkgUp.js +++ b/utils/pkgUp.js @@ -31,10 +31,13 @@ const path = require('path'); * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ + +/** @type {(filename: string | string[], cwd?: string) => string | null} */ function findUp(filename, cwd) { let dir = path.resolve(cwd || ''); const root = path.parse(dir).root; + /** @type {string[]} */ // @ts-expect-error TS sucks with concat const filenames = [].concat(filename); // eslint-disable-next-line no-constant-condition @@ -52,6 +55,7 @@ function findUp(filename, cwd) { } } +/** @type {import('./pkgUp').default} */ exports.default = function pkgUp(opts) { return findUp('package.json', opts && opts.cwd); }; diff --git a/utils/readPkgUp.d.ts b/utils/readPkgUp.d.ts new file mode 100644 index 0000000000..5fc1668879 --- /dev/null +++ b/utils/readPkgUp.d.ts @@ -0,0 +1,5 @@ +import pkgUp from './pkgUp'; + +declare function readPkgUp(opts?: Parameters[0]): {} | { pkg: string, path: string }; + +export default readPkgUp; diff --git a/utils/readPkgUp.js b/utils/readPkgUp.js index d34fa6c818..08371931f2 100644 --- a/utils/readPkgUp.js +++ b/utils/readPkgUp.js @@ -5,6 +5,7 @@ exports.__esModule = true; const fs = require('fs'); const pkgUp = require('./pkgUp').default; +/** @type {(str: string) => string} */ function stripBOM(str) { return str.replace(/^\uFEFF/, ''); } @@ -35,6 +36,7 @@ function stripBOM(str) { * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ +/** @type {import('./readPkgUp').default} */ exports.default = function readPkgUp(opts) { const fp = pkgUp(opts); diff --git a/utils/resolve.d.ts b/utils/resolve.d.ts new file mode 100644 index 0000000000..bb885bcfaf --- /dev/null +++ b/utils/resolve.d.ts @@ -0,0 +1,30 @@ +import type { Rule } from 'eslint'; + +import type ModuleCache from './ModuleCache'; +import type { ESLintSettings } from './types'; + +export type ResultNotFound = { found: false, path?: undefined }; +export type ResultFound = { found: true, path: string | null }; +export type ResolvedResult = ResultNotFound | ResultFound; + +export type ResolverResolve = (modulePath: string, sourceFile:string, config: unknown) => ResolvedResult; +export type ResolverResolveImport = (modulePath: string, sourceFile:string, config: unknown) => string | undefined; +export type Resolver = { interfaceVersion?: 1 | 2, resolve: ResolverResolve, resolveImport: ResolverResolveImport }; + +declare function resolve( + p: string, + context: Rule.RuleContext, +): ResolvedResult['path']; + +export default resolve; + +declare function fileExistsWithCaseSync( + filepath: string | null, + cacheSettings: ESLintSettings, + strict: boolean +): boolean | ReturnType; + +declare function relative(modulePath: string, sourceFile: string, settings: ESLintSettings): ResolvedResult['path']; + + +export { fileExistsWithCaseSync, relative }; diff --git a/utils/resolve.js b/utils/resolve.js index 0ed5bdb0c9..5a3084351e 100644 --- a/utils/resolve.js +++ b/utils/resolve.js @@ -19,16 +19,30 @@ const fileExistsCache = new ModuleCache(); // Polyfill Node's `Module.createRequireFromPath` if not present (added in Node v10.12.0) // Use `Module.createRequire` if available (added in Node v12.2.0) -const createRequire = Module.createRequire || Module.createRequireFromPath || function (filename) { - const mod = new Module(filename, null); - mod.filename = filename; - mod.paths = Module._nodeModulePaths(path.dirname(filename)); - - mod._compile(`module.exports = require;`, filename); - - return mod.exports; -}; +const createRequire = Module.createRequire + // @ts-expect-error this only exists in older node + || Module.createRequireFromPath + || /** @type {(filename: string) => unknown} */ function (filename) { + const mod = new Module(filename, void null); + mod.filename = filename; + // @ts-expect-error _nodeModulePaths is undocumented + mod.paths = Module._nodeModulePaths(path.dirname(filename)); + + // @ts-expect-error _compile is undocumented + mod._compile(`module.exports = require;`, filename); + + return mod.exports; + }; + +/** @type {(resolver: object) => resolver is import('./resolve').Resolver} */ +function isResolverValid(resolver) { + if ('interfaceVersion' in resolver && resolver.interfaceVersion === 2) { + return 'resolve' in resolver && !!resolver.resolve && typeof resolver.resolve === 'function'; + } + return 'resolveImport' in resolver && !!resolver.resolveImport && typeof resolver.resolveImport === 'function'; +} +/** @type {(target: T, sourceFile?: string | null | undefined) => undefined | ReturnType} */ function tryRequire(target, sourceFile) { let resolved; try { @@ -51,7 +65,58 @@ function tryRequire(target, sourceFile) { return require(resolved); } +/** @type {>(resolvers: string[] | string | { [k: string]: string }, map: T) => T} */ +function resolverReducer(resolvers, map) { + if (Array.isArray(resolvers)) { + resolvers.forEach((r) => resolverReducer(r, map)); + return map; + } + + if (typeof resolvers === 'string') { + map.set(resolvers, null); + return map; + } + + if (typeof resolvers === 'object') { + for (const key in resolvers) { + map.set(key, resolvers[key]); + } + return map; + } + + const err = new Error('invalid resolver config'); + err.name = ERROR_NAME; + throw err; +} + +/** @type {(sourceFile: string) => string} */ +function getBaseDir(sourceFile) { + return pkgDir(sourceFile) || process.cwd(); +} + +/** @type {(name: string, sourceFile: string) => import('./resolve').Resolver} */ +function requireResolver(name, sourceFile) { + // Try to resolve package with conventional name + const resolver = tryRequire(`eslint-import-resolver-${name}`, sourceFile) + || tryRequire(name, sourceFile) + || tryRequire(path.resolve(getBaseDir(sourceFile), name)); + + if (!resolver) { + const err = new Error(`unable to load resolver "${name}".`); + err.name = ERROR_NAME; + throw err; + } + if (!isResolverValid(resolver)) { + const err = new Error(`${name} with invalid interface loaded as resolver`); + err.name = ERROR_NAME; + throw err; + } + + return resolver; +} + // https://stackoverflow.com/a/27382838 +/** @type {import('./resolve').fileExistsWithCaseSync} */ exports.fileExistsWithCaseSync = function fileExistsWithCaseSync(filepath, cacheSettings, strict) { // don't care if the FS is case-sensitive if (CASE_SENSITIVE_FS) { return true; } @@ -80,12 +145,10 @@ exports.fileExistsWithCaseSync = function fileExistsWithCaseSync(filepath, cache return result; }; -function relative(modulePath, sourceFile, settings) { - return fullResolve(modulePath, sourceFile, settings).path; -} - +/** @type {import('./types').ESLintSettings | null} */ let prevSettings = null; let memoizedHash = ''; +/** @type {(modulePath: string, sourceFile: string, settings: import('./types').ESLintSettings) => import('./resolve').ResolvedResult} */ function fullResolve(modulePath, sourceFile, settings) { // check if this is a bonus core module const coreSet = new Set(settings['import/core-modules']); @@ -105,10 +168,12 @@ function fullResolve(modulePath, sourceFile, settings) { const cachedPath = fileExistsCache.get(cacheKey, cacheSettings); if (cachedPath !== undefined) { return { found: true, path: cachedPath }; } + /** @type {(resolvedPath: string | null) => void} */ function cache(resolvedPath) { fileExistsCache.set(cacheKey, resolvedPath); } + /** @type {(resolver: import('./resolve').Resolver, config: unknown) => import('./resolve').ResolvedResult} */ function withResolver(resolver, config) { if (resolver.interfaceVersion === 2) { return resolver.resolve(modulePath, sourceFile, config); @@ -145,71 +210,22 @@ function fullResolve(modulePath, sourceFile, settings) { // cache(undefined) return { found: false }; } -exports.relative = relative; - -function resolverReducer(resolvers, map) { - if (Array.isArray(resolvers)) { - resolvers.forEach((r) => resolverReducer(r, map)); - return map; - } - - if (typeof resolvers === 'string') { - map.set(resolvers, null); - return map; - } - - if (typeof resolvers === 'object') { - for (const key in resolvers) { - map.set(key, resolvers[key]); - } - return map; - } - - const err = new Error('invalid resolver config'); - err.name = ERROR_NAME; - throw err; -} -function getBaseDir(sourceFile) { - return pkgDir(sourceFile) || process.cwd(); -} -function requireResolver(name, sourceFile) { - // Try to resolve package with conventional name - const resolver = tryRequire(`eslint-import-resolver-${name}`, sourceFile) - || tryRequire(name, sourceFile) - || tryRequire(path.resolve(getBaseDir(sourceFile), name)); - - if (!resolver) { - const err = new Error(`unable to load resolver "${name}".`); - err.name = ERROR_NAME; - throw err; - } - if (!isResolverValid(resolver)) { - const err = new Error(`${name} with invalid interface loaded as resolver`); - err.name = ERROR_NAME; - throw err; - } - - return resolver; -} - -function isResolverValid(resolver) { - if (resolver.interfaceVersion === 2) { - return resolver.resolve && typeof resolver.resolve === 'function'; - } else { - return resolver.resolveImport && typeof resolver.resolveImport === 'function'; - } +/** @type {import('./resolve').relative} */ +function relative(modulePath, sourceFile, settings) { + return fullResolve(modulePath, sourceFile, settings).path; } +exports.relative = relative; +/** @type {Set} */ const erroredContexts = new Set(); /** * Given - * @param {string} p - module path - * @param {object} context - ESLint context - * @return {string} - the full module filesystem path; - * null if package is core; - * undefined if not found + * @param p - module path + * @param context - ESLint context + * @return - the full module filesystem path; null if package is core; undefined if not found + * @type {import('./resolve').default} */ function resolve(p, context) { try { @@ -218,8 +234,11 @@ function resolve(p, context) { if (!erroredContexts.has(context)) { // The `err.stack` string starts with `err.name` followed by colon and `err.message`. // We're filtering out the default `err.name` because it adds little value to the message. + // @ts-expect-error this might be an Error let errMessage = err.message; + // @ts-expect-error this might be an Error if (err.name !== ERROR_NAME && err.stack) { + // @ts-expect-error this might be an Error errMessage = err.stack.replace(/^Error: /, ''); } context.report({ diff --git a/utils/tsconfig.json b/utils/tsconfig.json new file mode 100644 index 0000000000..9e6fbc5cc1 --- /dev/null +++ b/utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ljharb/tsconfig", + "compilerOptions": { + "target": "ES2017", + "moduleResolution": "node", + "maxNodeModuleJsDepth": 0, + }, + "exclude": [ + "coverage", + ], +} diff --git a/utils/types.d.ts b/utils/types.d.ts new file mode 100644 index 0000000000..e0c4f5749d --- /dev/null +++ b/utils/types.d.ts @@ -0,0 +1,9 @@ +import type { Rule } from 'eslint'; + +export type Extension = `.${string}`; + +export type ESLintSettings = NonNullable & { + 'import/extensions'?: Extension[]; + 'import/parsers'?: { [k: string]: Extension[] }; + 'import/cache'?: { lifetime: number | '∞' | 'Infinity' }; +}; diff --git a/utils/unambiguous.d.ts b/utils/unambiguous.d.ts new file mode 100644 index 0000000000..1679224189 --- /dev/null +++ b/utils/unambiguous.d.ts @@ -0,0 +1,7 @@ +import type { AST } from 'eslint'; + +declare function isModule(ast: AST.Program): boolean; + +declare function test(content: string): boolean; + +export { isModule, test } diff --git a/utils/unambiguous.js b/utils/unambiguous.js index 24cb123157..20aabd1bd4 100644 --- a/utils/unambiguous.js +++ b/utils/unambiguous.js @@ -11,7 +11,7 @@ const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m; * * Not perfect, just a fast way to disqualify large non-ES6 modules and * avoid a parse. - * @type {RegExp} + * @type {import('./unambiguous').test} */ exports.test = function isMaybeUnambiguousModule(content) { return pattern.test(content); @@ -22,8 +22,7 @@ const unambiguousNodeType = /^(?:(?:Exp|Imp)ort.*Declaration|TSExportAssignment) /** * Given an AST, return true if the AST unambiguously represents a module. - * @param {Program node} ast - * @return {Boolean} + * @type {import('./unambiguous').isModule} */ exports.isModule = function isUnambiguousModule(ast) { return ast.body && ast.body.some((node) => unambiguousNodeType.test(node.type)); diff --git a/utils/visit.d.ts b/utils/visit.d.ts new file mode 100644 index 0000000000..50559aaab0 --- /dev/null +++ b/utils/visit.d.ts @@ -0,0 +1,9 @@ +import type { Node } from 'estree'; + +declare function visit( + node: Node, + keys: { [k in Node['type']]?: (keyof Node)[] }, + visitorSpec: { [k in Node['type'] | `${Node['type']}:Exit`]?: Function } +): void; + +export default visit; diff --git a/utils/visit.js b/utils/visit.js index 6178faeaa0..dd0c6248da 100644 --- a/utils/visit.js +++ b/utils/visit.js @@ -2,24 +2,29 @@ exports.__esModule = true; +/** @type {import('./visit').default} */ exports.default = function visit(node, keys, visitorSpec) { if (!node || !keys) { return; } const type = node.type; - if (typeof visitorSpec[type] === 'function') { - visitorSpec[type](node); + const visitor = visitorSpec[type]; + if (typeof visitor === 'function') { + visitor(node); } const childFields = keys[type]; if (!childFields) { return; } childFields.forEach((fieldName) => { + // @ts-expect-error TS sucks with concat [].concat(node[fieldName]).forEach((item) => { visit(item, keys, visitorSpec); }); }); - if (typeof visitorSpec[`${type}:Exit`] === 'function') { - visitorSpec[`${type}:Exit`](node); + + const exit = visitorSpec[`${type}:Exit`]; + if (typeof exit === 'function') { + exit(node); } };