Repository: knowledge-work/eslint-plugin-strict-dependencies Branch: main Commit: 63c77c2cc3ec Files: 14 Total size: 33.2 KB Directory structure: gitextract_d7db3qfd/ ├── .github/ │ ├── renovate.json5 │ └── workflows/ │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── __tests__/ │ ├── index.js │ └── resolveImportPath.js ├── index.js ├── jest.config.js ├── package.json └── strict-dependencies/ ├── index.js └── resolveImportPath.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/renovate.json5 ================================================ { extends: [ 'config:base', ':disableRateLimiting', ':maintainLockFilesWeekly', ':automergeAll', 'group:nodeJs', 'helpers:pinGitHubActionDigests', ], timezone: 'Asia/Tokyo', postUpdateOptions: ['yarnDedupeHighest'], minimumReleaseAge: '7 days', } ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: branches: [main] jobs: jest: name: Jest runs-on: ubuntu-latest timeout-minutes: 5 strategy: matrix: node: ['18', '20', '22'] steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: node-version: ${{ matrix.node }} cache: 'yarn' - run: yarn - run: yarn test ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish on: workflow_dispatch: inputs: versionClass: type: choice required: true options: - 'patch' - 'minor' - 'major' schedule: - cron: '0 0 15 * *' # at AM 9 JST on day of month 15 concurrency: group: ${{ github.workflow }} cancel-in-progress: true permissions: id-token: write # Required for OIDC contents: write # create tag and release jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: node-version: '24' cache: 'yarn' registry-url: 'https://registry.npmjs.org' # Trusted publishing requires npm CLI version 11.5.1 or later. - name: Ensure npm >= 11.5.1 run: npm i -g npm@^11.5.1 - name: Install dependencies run: yarn install --frozen-lockfile - name: Bump version and publish id: bump-version run: | git config user.name '[bot] github action (${{ github.workflow }})' git config user.email 'engineer-team@knowledgework.com' yarn version --${{ github.event_name == 'schedule' && 'patch' || github.event.inputs.versionClass }} yarn run publish echo "new_version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" - name: Create release uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | await github.rest.repos.createRelease({ owner: "${{github.repository_owner}}", repo: "${{github.repository}}".split('/')[1], tag_name: "v${{ steps.bump-version.outputs.new_version }}", generate_release_notes: true, }) ================================================ FILE: .gitignore ================================================ /node_modules/ /.idea/ /.vscode/ /coverage/ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Knowledge Work Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # eslint-plugin-strict-dependencies ESLint plugin to define custom module dependency rules. NOTE: `eslint-plugin-strict-dependencies` uses tsconfig, tsconfig.json must be present. ## Installation ``` npm install eslint-plugin-strict-dependencies --save-dev ``` ## Supported Rules - strict-dependencies - module: `string` (Glob or Forward matching string) - Target module path - targetMembers: `string[]` - Target member name - e.x. `["Suspense"]` in `import { Suspense } from 'react'` - allowReferenceFrom: `string[]` (Glob or Forward matching string) - Paths of files where target module imports are allowed. - allowSameModule: `boolean` - Whether it can be imported by other files in the same directory - excludeTypeImportChecks: `boolean` - Whether to exclude type import checks - e.x. `import type { Suspense } from 'react'` ### Options - resolveRelativeImport: `boolean[default = false]` - Whether to resolve relative import as in the following example - `src/components/aaa.ts` ```typescript import bbb from './bbb'; ``` - `resolveRelativeImport = false`: Resolve as `./bbb` (excluded from lint target) - `resolveRelativeImport = true`: Resolve as `src/components/bbb`: (included from lint target) - pathIndexMap: `object[default = null]` - In eslint-plugin-strict-dependencies, path alias resolution is performed based on the paths specified in the tsconfig. - By default, the value with an index number of `0` is used, but you can specify an option to use a value with any index number. - Specify it as in the following example: - `tsconfig.json` ```json { "compilerOptions": { "*": ["aaa/*", "bbb/*"] }, } ``` - `pathIndexMap = { "*": 1 } `: `"bbb/*"` is used. ## Usage .eslintrc: ```js "plugins": [ "strict-dependencies", ], "rules": { "strict-dependencies/strict-dependencies": [ "error", [ /** * Example: * Limit the dependencies in the following directions * pages -> components/page -> components/ui */ { "module": "src/components/page", "allowReferenceFrom": ["src/pages"], // components/page can't import other components/page "allowSameModule": false }, { "module": "src/components/ui", "allowReferenceFrom": ["src/components/page"], // components/ui can import other components/ui "allowSameModule": true, // components/ui exclude type import checks "excludeTypeImportChecks": true }, /** * example: * Disallow to import `next/router` directly. it should always be imported using `libs/router.ts`. */ { "module": "next/router", "allowReferenceFrom": ["src/libs/router.ts"], "allowSameModule": false }, /** * example: * Disallow to import Suspense from react. it should always be imported using `libs/react.ts`. */ { "module": "react", "targetMembers": ["Suspense"], "allowReferenceFrom": ["src/libs/react.ts"], "allowSameModule": false }, ], // options // { // "resolveRelativeImport": true // "pathIndexMap": { "*": 1 } // } ] } ``` ## License MIT ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting Security Issues Please do not report security vulnerabilities through public GitHub issues. Instead, please report them to the Security Team. ``` Knowledge Work Security Team info-security@knowledgework.com ``` ================================================ FILE: __tests__/index.js ================================================ const {create} = require('../strict-dependencies') const path = require('path') const resolveImportPath = require('../strict-dependencies/resolveImportPath') jest.mock('../strict-dependencies/resolveImportPath') const mockImportDeclaration = { type: 'ImportDeclaration', start: 72, end: 116, specifiers: [ // specifiersにはImportDefaultSpecifier/ImportNamespaceSpecifier/ImportSpecifierがあり、実際には同時に動くことはないが念のため3つともテストケースに対して用意する { // import * as React from 'react' "type": "ImportNamespaceSpecifier", "start": 52, "end": 62, "local": { "type": "Identifier", "start": 57, "end": 62, "name": "React" } }, { // import DefaultExport from '@/components/ui/Text'; "type": "ImportDefaultSpecifier", "start": 79, "end": 92, "local": { "type": "Identifier", "start": 79, "end": 92, "name": "DefaultExport" } }, { // import { Text, TextProps } from '@/components/ui/Text'; 'type': 'ImportSpecifier', 'start': 81, 'end': 85, 'imported': { 'type': 'Identifier', 'start': 81, 'end': 85, 'name': 'Text' }, 'local': { 'type': 'Identifier', 'start': 81, 'end': 85, 'name': 'Text' } }, { 'type': 'ImportSpecifier', 'start': 87, 'end': 96, 'imported': { 'type': 'Identifier', 'start': 87, 'end': 96, 'name': 'TextProps' }, 'local': { 'type': 'Identifier', 'start': 87, 'end': 96, 'name': 'TextProps' } } ], source: { type: 'Literal', start: 93, end: 115, value: '@/components/ui/Text', raw: '"@/components/ui/Text"', }, }; const mockImportDeclarationImportKindType = { importKind: 'type', ...mockImportDeclaration }; describe('create', () => { it('should return object', () => { const created = create({options: [[]]}) expect(typeof created).toBe('object') expect(created).toHaveProperty('ImportDeclaration') }) }) describe('create.ImportDeclaration', () => { it('should do nothing if no dependencies', () => { resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/aaa/bbb.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [[]], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should do nothing if not matched with importPath', () => { // importPath: src/components/ui/Text // dependency.module: src/libs resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/aaa/bbb.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/libs'}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should not report if allowed', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/pages/aaa.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/components/pages'], allowSameModule: true}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should not report if allowed with glob pattern', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.allowReferenceFrom: ['src/components/**/*.ts'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/pages/aaa.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/components/**/*.ts'], allowSameModule: true}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should report if not allowed', () => { // relativePath: src/components/test/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/test/aaa.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/components/pages'], allowSameModule: true}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(1) expect(report.mock.calls[0][1]).toBe('import \'src/components/ui/Text\' is not allowed from src/components/test/aaa.ts.') }) it('should report if not allowed with glob pattern', () => { // relativePath: src/components/test/aaa.tsx // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.allowReferenceFrom: ['src/components/**/*.ts'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/test/aaa.tsx')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/components/**/*.ts'], allowSameModule: true}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(1) expect(report.mock.calls[0][1]).toBe('import \'src/components/ui/Text\' is not allowed from src/components/test/aaa.tsx.') }) it('should not report if allowed from same module', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/ui/aaa.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/aaa'], allowSameModule: true}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should report if not allowed from same module', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/ui/aaa.ts') ) const report = jest.fn() const { ImportDeclaration: checkImport } = create({ options: [ [ { module: 'src/components/ui', allowReferenceFrom: ['src/aaa'], allowSameModule: false, }, ], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(1) expect(report.mock.calls[0][1]).toBe('import \'src/components/ui/Text\' is not allowed from src/components/ui/aaa.ts.') }) it('should report if not allowed specifier from target module', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.targetMembers: ['Text'], dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/pages/index.tsx') ) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/aaa'], allowSameModule: false}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(1) expect(report.mock.calls[0][1]).toBe('import \'src/components/ui/Text\' is not allowed from src/pages/index.tsx.') }) it('should not report if allowed specifier from target module', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.targetMembers: ['Text'], dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/pages/index.tsx') ) const report = jest.fn() const { ImportDeclaration: checkImport } = create({ options: [ [ { module: 'src/components/ui', targetMembers: ['Text'], allowReferenceFrom: ['src/pages'], }, ], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(0) expect(report).not.toBeCalled() }) it('should report if not allowed specifier from target module', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.targetMembers: ['Text'], dependency.allowReferenceFrom: ['src/components/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/button.tsx') ) const report = jest.fn() const { ImportDeclaration: checkImport } = create({ options: [ [ { module: 'src/components/ui', targetMembers: ['Text'], allowReferenceFrom: ['src/pages'], }, ], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(1) expect(report.mock.calls[0][1]).toBe('import specifier \'Text\' is not allowed from src/components/button.tsx.') }) it('should report if not allowed multiple specifiers from target module', () => { // relativePath: src/components/button.tsx // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.targetMembers: ['Text', 'TextProps'], dependency.allowReferenceFrom: ['src/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/button.tsx') ) const report = jest.fn() const { ImportDeclaration: checkImport } = create({ options: [ [ { module: 'src/components/ui', targetMembers: ['Text', 'TextProps'], allowReferenceFrom: ['src/pages'], }, ], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(1) expect(report.mock.calls[0][1]).toBe('import specifier \'Text, TextProps\' is not allowed from src/components/button.tsx.') }) it('should not report if only allowed specifier from target module', () => { // relativePath: src/components/pages/aaa.ts // importPath: src/components/ui/Text // dependency.module: src/components/ui, dependency.targetMembers: ['SomeRestrictedModule'], dependency.allowReferenceFrom: ['src/pages'], allowSameModule: true resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/button.tsx') ) const report = jest.fn() const { ImportDeclaration: checkImport } = create({ options: [ [ { module: 'src/components/ui', targetMembers: ['SomeRestrictedModule'], allowReferenceFrom: ['src/pages'], }, ], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(getFilename).toBeCalledTimes(1) expect(report.mock.calls).toHaveLength(0) expect(report).not.toBeCalled(); }) it('should pass relativeFilePath value to resolveImportPath if resolveRelativeImport is true', () => { resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/ui/aaa.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/aaa'], allowSameModule: true}], {resolveRelativeImport: true}, ], getFilename, report, }) checkImport(mockImportDeclaration) expect(resolveImportPath).toBeCalledWith('@/components/ui/Text', 'src/components/ui/aaa.ts', {}) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should pass empty relativeFilePath value to resolveImportPath if resolveRelativeImport is falsy', () => { resolveImportPath.mockReturnValue('../components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/components/ui/aaa.ts')) const report = jest.fn() const {ImportDeclaration: checkImport} = create({ options: [ [{module: 'src/components/ui', allowReferenceFrom: ['src/aaa'], allowSameModule: true}], ], getFilename, report, }) checkImport(mockImportDeclaration) expect(resolveImportPath).toBeCalledWith('@/components/ui/Text', null, {}) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) it('should not report if excludeTypeImportChecks is true', () => { resolveImportPath.mockReturnValue('src/components/ui/Text') const getFilename = jest.fn(() => path.join(process.cwd(), 'src/pages/index.tsx') ) const report = jest.fn() const { ImportDeclaration: checkImport } = create({ options: [ [ { module: 'src/components/ui', allowReferenceFrom: ['src/aaa'], allowSameModule: false, excludeTypeImportChecks: true, }, ], ], getFilename, report, }) checkImport(mockImportDeclarationImportKindType) expect(getFilename).toBeCalledTimes(1) expect(report).not.toBeCalled() }) }) ================================================ FILE: __tests__/resolveImportPath.js ================================================ const resolveImportPath = require('../strict-dependencies/resolveImportPath') const {readFileSync} = require('fs') jest.mock('fs') describe('resolveImportPath', () => { it('should resolve relative path', () => { // > src/pages/aaa/bbb.ts // import Text from '../../components/ui/Text' readFileSync.mockReturnValue(JSON.stringify({})) expect(resolveImportPath('../../components/ui/Text', 'src/pages/aaa/bbb.ts', {})).toBe('src/components/ui/Text') }) it('should not resolve relative path if relativeFilePath is empty', () => { // > src/pages/aaa/bbb.ts // import Text from '../../components/ui/Text' readFileSync.mockReturnValue(JSON.stringify({})) expect(resolveImportPath('../../components/ui/Text', null, {})).toBe('../../components/ui/Text') }) it('should do nothing if tsconfig.json does not exist', () => { readFileSync.mockImplementation(() => { throw new Error() }) expect(resolveImportPath('components/aaa/bbb', null, {})).toBe('components/aaa/bbb') }) it('should do nothing if no paths setting', () => { readFileSync.mockReturnValue(JSON.stringify({})) expect(resolveImportPath('components/aaa/bbb', null, {})).toBe('components/aaa/bbb') }) describe('should resolve tsconfig paths', () => { [ ['@/components/', 'components/', 'components/aaa/bbb'], ['@/components', 'components', 'components/aaa/bbb'], ['@/components/*', 'components/*', 'components/aaa/bbb'], ].forEach(([target, resolve, expected]) => { it(`${target}: [${resolve}]`, () => { readFileSync.mockReturnValue(JSON.stringify({ compilerOptions: { paths: { [target]: [resolve], }, }, })) expect(resolveImportPath('components/aaa/bbb', null, {})).toBe('components/aaa/bbb') expect(resolveImportPath('@/components/aaa/bbb', null, {})).toBe(expected) }) }) }) describe('should resolve tsconfig paths with baseUrl', () => { [ ['.', 'components/aaa/bbb'], ['./', 'components/aaa/bbb'], ['../', '../components/aaa/bbb'], ['src', 'src/components/aaa/bbb'], ['./src', 'src/components/aaa/bbb'], ['src/', 'src/components/aaa/bbb'], ['./src/', 'src/components/aaa/bbb'], ].forEach(([baseUrl, expected]) => { it(baseUrl, () => { readFileSync.mockReturnValue(JSON.stringify({ compilerOptions: { baseUrl, paths: { '@/components/': ['components/'], }, }, })) expect(resolveImportPath('components/aaa/bbb', null, {})).toBe('components/aaa/bbb') expect(resolveImportPath('@/components/aaa/bbb', null, {})).toBe(expected) }) }) }) describe('resolveImportPath with pathIndexMap parameter', () => { const tsConfigWithMultiplePaths = JSON.stringify({ compilerOptions: { paths: { '@/components/*': ['src/components/*', 'src/alternativeComponents/*'], }, }, }); it('should resolve path alias with specified index in pathIndexMap', () => { readFileSync.mockReturnValue(tsConfigWithMultiplePaths); expect(resolveImportPath('@/components/aaa/bbb', null, { '@/components/*': 1 })).toBe('src/alternativeComponents/aaa/bbb'); }); it('should resolve path alias with default index:0 if specified index does not exist', () => { readFileSync.mockReturnValue(tsConfigWithMultiplePaths); expect(resolveImportPath('@/components/aaa/bbb', null, { '@/components/*': 5 })).toBe('src/components/aaa/bbb'); }); it('should resolve path alias with default index:0 if pathIndexMap is an empty object', () => { readFileSync.mockReturnValue(tsConfigWithMultiplePaths); expect(resolveImportPath('@/components/aaa/bbb', null, {})).toBe('src/components/aaa/bbb'); }); }) }) ================================================ FILE: index.js ================================================ /* eslint-disable */ const strictDependencies = require('./strict-dependencies') module.exports = { rules: { 'strict-dependencies': strictDependencies, }, } ================================================ FILE: jest.config.js ================================================ module.exports = { clearMocks: true, } ================================================ FILE: package.json ================================================ { "name": "eslint-plugin-strict-dependencies", "description": "ESlint plugin to define custom module dependency rules.", "version": "1.3.33", "repository": { "type": "git", "url": "https://github.com/knowledge-work/eslint-plugin-strict-dependencies.git" }, "keywords": [ "eslint", "eslintplugin", "lint", "rule", "check", "import", "module", "directory", "strict", "dependencies" ], "license": "MIT", "main": "index.js", "files": [ "strict-dependencies", "index.js" ], "dependencies": { "is-glob": "4.0.3", "micromatch": "4.0.8", "normalize-path": "3.0.0", "require-strip-json-comments": "2.0.0" }, "devDependencies": { "jest": "29.7.0" }, "scripts": { "test": "jest --coverage", "preversion": "echo \"Run check for version $npm_package_version\" && yarn run test", "publish": "git push origin main && git push --tags && npm publish . --access public --registry=https://registry.npmjs.org" } } ================================================ FILE: strict-dependencies/index.js ================================================ /* eslint-disable */ const path = require('path') const mm = require('micromatch') const isGlob = require('is-glob') const normalize = require('normalize-path') const resolveImportPath = require('./resolveImportPath') /** * pathのmatcher。 * eslintrcで設定できる値は以下のケースを扱う * - globパターン指定 * - globパターン以外の場合 => 前方部分一致 */ const isMatch = (str, pattern) => isGlob(pattern) ? mm.isMatch(str, pattern) : str.startsWith(pattern) module.exports = { meta: { type: 'suggestion', schema: [ { type: 'array', items: [ { type: 'object', properties: { module: { type: 'string', }, allowReferenceFrom: { type: 'array', items: [ { type: 'string', }, ], }, allowSameModule: { type: 'boolean', }, excludeTypeImportChecks: { type: 'boolean' } }, }, ], }, { type: 'object', properties: { resolveRelativeImport: { type: 'boolean', }, pathIndexMap: { type: 'object' } }, }, ], }, create: (context) => { const dependencies = context.options[0] const options = context.options.length > 1 ? context.options[1] : {} const resolveRelativeImport = options.resolveRelativeImport const pathIndexMap = options.pathIndexMap ? options.pathIndexMap : {} function checkImport(node) { const fileFullPath = context.getFilename() const relativeFilePath = normalize(path.relative(process.cwd(), fileFullPath)) const importPath = resolveImportPath(node.source.value, resolveRelativeImport ? relativeFilePath : null, pathIndexMap) // specにはImportDefaultSpecifier/ImportNamespaceSpecifier/ImportSpecifierがあり、ImportSpecifierの場合はimportedが存在する const importedModules = node.specifiers.filter(spec => 'imported' in spec).map(spec => spec.imported.name) dependencies .forEach((dependency) => { // そもそもmoduleがimportPathと一致していない場合は必ず報告しない if (!isMatch(importPath, dependency.module)) return; /** * 1. 参照元チェックをしてAllowであればそこで処理を終了する * 2. targetMembersが指定されていれば、targetMembersと実際にimportしているモジュールを比較して含まれていればエラーレポートして処理を終了する * 3. targetMembersが指定されていない場合は、エラー扱いなのでエラーレポートして処理を終了する */ const isAllowedByPath = // 参照元が許可されている dependency.allowReferenceFrom.some((allowPath) => isMatch(relativeFilePath, allowPath), ) || // または同一モジュール間の参照が許可されている場合 (dependency.allowSameModule && isMatch(relativeFilePath, dependency.module)) // または明示的に対象外としたtype importである場合 || (dependency.excludeTypeImportChecks && node.importKind === 'type') if (isAllowedByPath) return // importedされた値・型名もLintのターゲットに入っている場合の検出処理 function getCommonElements(arrA, arrB) { return arrA.filter(element => arrB.includes(element)); } if (dependency.targetMembers && Array.isArray(dependency.targetMembers)) { const commonImportedList = getCommonElements(dependency.targetMembers, importedModules) if (commonImportedList.length > 0) { context.report(node, `import specifier '${commonImportedList.join(', ')}' is not allowed from ${relativeFilePath}.`) } return; } context.report(node, `import '${importPath}' is not allowed from ${relativeFilePath}.`) }) } return { ImportDeclaration: checkImport, } }, } ================================================ FILE: strict-dependencies/resolveImportPath.js ================================================ /* eslint-disable */ const path = require('path') const parseJSON = require('require-strip-json-comments') const normalize = require('normalize-path') /** * import文のrootからのパスを求める */ module.exports = (importPath, relativeFilePath, pathIndexMap) => { // { [importAlias: string]: OriginalPath } const importAliasMap = {} // Load tsconfig option // MEMO: tscとか使って簡単に読める方法がありそう try { const tsConfigFilePath = path.join(process.cwd(), '/tsconfig.json') // Exists ts config const tsConfig = parseJSON(tsConfigFilePath) if (tsConfig.compilerOptions && tsConfig.compilerOptions.paths) { Object.keys(tsConfig.compilerOptions.paths).forEach((key) => { const matchedKey = Object.keys(pathIndexMap).find(k => k === key) // MEMO: pathIndexMapの指定がない場合 or 指定されているindexにアクセスしても値が得られない場合は[0]固定 const pathIndex = matchedKey ? pathIndexMap[matchedKey] : 0 const pathValue = tsConfig.compilerOptions.paths[key][pathIndex] ? tsConfig.compilerOptions.paths[key][pathIndex] : tsConfig.compilerOptions.paths[key][0] importAliasMap[key] = tsConfig.compilerOptions.baseUrl ? path.join(tsConfig.compilerOptions.baseUrl, pathValue) : pathValue }) } } catch (e) { // DO NOTHING } if (relativeFilePath && (importPath.startsWith('./') || importPath.startsWith('../'))) { importPath = path.join(path.dirname(relativeFilePath), importPath) } const absolutePath = Object.keys(importAliasMap).reduce((resolvedImportPath, key) => { // FIXME: use glob module instead of replace('*') return resolvedImportPath.replace(key.replace('*', ''), importAliasMap[key].replace('*', '')) }, importPath) return normalize(absolutePath) }