diff --git a/e2e/linter/src/linter.test.ts b/e2e/linter/src/linter.test.ts index 607b9c0068a139..fedba0a6aefc16 100644 --- a/e2e/linter/src/linter.test.ts +++ b/e2e/linter/src/linter.test.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { checkFilesExist, + createFile, newProject, readFile, readJson, @@ -9,6 +10,7 @@ import { updateFile, } from '@nrwl/e2e/utils'; import * as ts from 'typescript'; +import { names } from '@nrwl/devkit'; describe('Linter', () => { describe('linting errors', () => { @@ -191,6 +193,160 @@ describe('Linter', () => { expect(lintOutput).toContain(knownLintErrorMessage); }, 1000000); }); + + it('lint plugin should ensure module boundaries', () => { + const proj = newProject(); + const myapp = uniq('myapp'); + const myapp2 = uniq('myapp2'); + const mylib = uniq('mylib'); + const lazylib = uniq('lazylib'); + const invalidtaglib = uniq('invalidtaglib'); + const validtaglib = uniq('validtaglib'); + + runCLI(`generate @nrwl/angular:app ${myapp} --tags=validtag`); + runCLI(`generate @nrwl/angular:app ${myapp2}`); + runCLI(`generate @nrwl/angular:lib ${mylib}`); + runCLI(`generate @nrwl/angular:lib ${lazylib}`); + runCLI(`generate @nrwl/angular:lib ${invalidtaglib} --tags=invalidtag`); + runCLI(`generate @nrwl/angular:lib ${validtaglib} --tags=validtag`); + + const eslint = readJson('.eslintrc.json'); + eslint.overrides[0].rules[ + '@nrwl/nx/enforce-module-boundaries' + ][1].depConstraints = [ + { sourceTag: 'validtag', onlyDependOnLibsWithTags: ['validtag'] }, + ...eslint.overrides[0].rules['@nrwl/nx/enforce-module-boundaries'][1] + .depConstraints, + ]; + updateFile('.eslintrc.json', JSON.stringify(eslint, null, 2)); + + const tsConfig = readJson('tsconfig.base.json'); + + /** + * apps do not add themselves to the tsconfig file. + * + * Let's add it so that we can trigger the lint failure + */ + tsConfig.compilerOptions.paths[`@${proj}/${myapp2}`] = [ + `apps/${myapp2}/src/main.ts`, + ]; + + tsConfig.compilerOptions.paths[`@secondScope/${lazylib}`] = + tsConfig.compilerOptions.paths[`@${proj}/${lazylib}`]; + delete tsConfig.compilerOptions.paths[`@${proj}/${lazylib}`]; + updateFile('tsconfig.base.json', JSON.stringify(tsConfig, null, 2)); + + updateFile( + `apps/${myapp}/src/main.ts`, + ` + import '../../../libs/${mylib}'; + import '@secondScope/${lazylib}'; + import '@${proj}/${myapp2}'; + import '@${proj}/${invalidtaglib}'; + import '@${proj}/${validtaglib}'; + + const s = {loadChildren: '@${proj}/${lazylib}'}; + ` + ); + + const out = runCLI(`lint ${myapp}`, { silenceError: true }); + expect(out).toContain( + 'Projects cannot be imported by a relative or absolute path, and must begin with a npm scope' + ); + // expect(out).toContain('Imports of lazy-loaded libraries are forbidden'); + expect(out).toContain('Imports of apps are forbidden'); + expect(out).toContain( + 'A project tagged with "validtag" can only depend on libs tagged with "validtag"' + ); + }, 1000000); + + describe('workspace boundary rules', () => { + const libA = uniq('lib-a'); + const libB = uniq('lib-b'); + const libC = uniq('lib-c'); + let projScope; + + beforeAll(() => { + projScope = newProject(); + runCLI(`generate @nrwl/workspace:lib ${libA}`); + runCLI(`generate @nrwl/workspace:lib ${libB}`); + runCLI(`generate @nrwl/workspace:lib ${libC}`); + + // updateFile(`apps/${myapp}/src/main.ts`, `console.log("should fail");`); + }); + + xdescribe('aaashould autofix noSelfCircularDependencies', () => { + beforeAll(() => { + /* + import { func1, func2 } from '@scope/same-lib'; + + should be transformed into + + import { func1 } from './func1'; + import { func2 } from './func2'; + */ + + createFile( + `libs/${libC}/src/lib/another-func.ts`, + ` + export function anotherFunc() { + return 'hi'; + } + ` + ); + + updateFile( + `libs/${libC}/src/lib/index.ts`, + ` + export * from './lib/${names(libC).fileName}'; + export * from './lib/another-func'; + ` + ); + + createFile( + `libs/${libC}/src/lib/lib-c-another.ts`, + ` +import { ${ + names(libC).propertyName + }, anotherFunc } from '@${projScope}/${libC}'; + +export function someStuff() { + anotherFunc(); + return ${names(libC).propertyName}(); +} + ` + ); + + // scenario 2 + }); + + it('should fix a circular self reference', () => { + const stdout = runCLI(`lint ${libC}`, { + silenceError: true, + }); + expect(stdout).toContain( + 'Projects should use relative imports to import from other files within the same project' + ); + + // fix them + const fixedStout = runCLI(`lint ${libC} --fix`, { + silenceError: true, + }); + expect(fixedStout).not.toContain( + 'Projects should use relative imports to import from other files within the same project' + ); + const fileContent = readFile(`libs/${libC}/src/lib/lib-c-another.ts`); + expect(fileContent).toContain( + `import { ${names(libC).propertyName} } from './${ + names(libC).fileName + }';` + ); + expect(fileContent).toContain( + `import { anotherFunc } from './another-func';` + ); + }); + }); + }); }); /** diff --git a/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.spec.ts b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.spec.ts index 9768cd19fbac76..6ddeeef2fb7188 100644 --- a/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.spec.ts +++ b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.spec.ts @@ -1039,169 +1039,177 @@ describe('Enforce Module Boundaries (eslint)', () => { expect(failures.length).toBe(0); }); - it('should error when circular dependency detected', () => { - const failures = runRule( - {}, - `${process.cwd()}/proj/libs/anotherlib/src/main.ts`, - ` + describe('circular dependencies', () => { + it('should error when circular dependency detected', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/anotherlib/src/main.ts`, + ` import '@mycompany/mylib'; import('@mycompany/mylib'); `, - { - nodes: { - mylibName: { - name: 'mylibName', - type: ProjectType.lib, - data: { - root: 'libs/mylib', - tags: [], - implicitDependencies: [], - architect: {}, - files: [createFile(`libs/mylib/src/main.ts`, ['anotherlibName'])], + { + nodes: { + mylibName: { + name: 'mylibName', + type: ProjectType.lib, + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [ + createFile(`libs/mylib/src/main.ts`, ['anotherlibName']), + ], + }, }, - }, - anotherlibName: { - name: 'anotherlibName', - type: ProjectType.lib, - data: { - root: 'libs/anotherlib', - tags: [], - implicitDependencies: [], - architect: {}, - files: [createFile(`libs/anotherlib/src/main.ts`, ['mylibName'])], + anotherlibName: { + name: 'anotherlibName', + type: ProjectType.lib, + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [ + createFile(`libs/anotherlib/src/main.ts`, ['mylibName']), + ], + }, }, - }, - myappName: { - name: 'myappName', - type: ProjectType.app, - data: { - root: 'apps/myapp', - tags: [], - implicitDependencies: [], - architect: {}, - files: [createFile(`apps/myapp/src/index.ts`)], + myappName: { + name: 'myappName', + type: ProjectType.app, + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + architect: {}, + files: [createFile(`apps/myapp/src/index.ts`)], + }, }, }, - }, - dependencies: { - mylibName: [ - { - source: 'mylibName', - target: 'anotherlibName', - type: DependencyType.static, - }, - ], - }, - } - ); + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + }, + } + ); - const message = `Circular dependency between "anotherlibName" and "mylibName" detected: anotherlibName -> mylibName -> anotherlibName + const message = `Circular dependency between "anotherlibName" and "mylibName" detected: anotherlibName -> mylibName -> anotherlibName Circular file chain: - libs/anotherlib/src/main.ts - libs/mylib/src/main.ts`; - expect(failures.length).toEqual(2); - expect(failures[0].message).toEqual(message); - expect(failures[1].message).toEqual(message); - }); + expect(failures.length).toEqual(2); + expect(failures[0].message).toEqual(message); + expect(failures[1].message).toEqual(message); + }); - it('should error when circular dependency detected (indirect)', () => { - const failures = runRule( - {}, - `${process.cwd()}/proj/libs/mylib/src/main.ts`, - ` + it('should error when circular dependency detected (indirect)', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` import '@mycompany/badcirclelib'; import('@mycompany/badcirclelib'); `, - { - nodes: { - mylibName: { - name: 'mylibName', - type: ProjectType.lib, - data: { - root: 'libs/mylib', - tags: [], - implicitDependencies: [], - architect: {}, - files: [ - createFile(`libs/mylib/src/main.ts`, ['badcirclelibName']), - ], + { + nodes: { + mylibName: { + name: 'mylibName', + type: ProjectType.lib, + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [ + createFile(`libs/mylib/src/main.ts`, ['badcirclelibName']), + ], + }, }, - }, - anotherlibName: { - name: 'anotherlibName', - type: ProjectType.lib, - data: { - root: 'libs/anotherlib', - tags: [], - implicitDependencies: [], - architect: {}, - files: [ - createFile(`libs/anotherlib/src/main.ts`, ['mylibName']), - createFile(`libs/anotherlib/src/index.ts`, ['mylibName']), - ], + anotherlibName: { + name: 'anotherlibName', + type: ProjectType.lib, + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [ + createFile(`libs/anotherlib/src/main.ts`, ['mylibName']), + createFile(`libs/anotherlib/src/index.ts`, ['mylibName']), + ], + }, }, - }, - badcirclelibName: { - name: 'badcirclelibName', - type: ProjectType.lib, - data: { - root: 'libs/badcirclelib', - tags: [], - implicitDependencies: [], - architect: {}, - files: [ - createFile(`libs/badcirclelib/src/main.ts`, ['anotherlibName']), - ], + badcirclelibName: { + name: 'badcirclelibName', + type: ProjectType.lib, + data: { + root: 'libs/badcirclelib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [ + createFile(`libs/badcirclelib/src/main.ts`, [ + 'anotherlibName', + ]), + ], + }, }, - }, - myappName: { - name: 'myappName', - type: ProjectType.app, - data: { - root: 'apps/myapp', - tags: [], - implicitDependencies: [], - architect: {}, - files: [createFile(`apps/myapp/index.ts`)], + myappName: { + name: 'myappName', + type: ProjectType.app, + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + architect: {}, + files: [createFile(`apps/myapp/index.ts`)], + }, }, }, - }, - dependencies: { - mylibName: [ - { - source: 'mylibName', - target: 'badcirclelibName', - type: DependencyType.static, - }, - ], - badcirclelibName: [ - { - source: 'badcirclelibName', - target: 'anotherlibName', - type: DependencyType.static, - }, - ], - anotherlibName: [ - { - source: 'anotherlibName', - target: 'mylibName', - type: DependencyType.static, - }, - ], - }, - } - ); + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'badcirclelibName', + type: DependencyType.static, + }, + ], + badcirclelibName: [ + { + source: 'badcirclelibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + anotherlibName: [ + { + source: 'anotherlibName', + target: 'mylibName', + type: DependencyType.static, + }, + ], + }, + } + ); - const message = `Circular dependency between "mylibName" and "badcirclelibName" detected: mylibName -> badcirclelibName -> anotherlibName -> mylibName + const message = `Circular dependency between "mylibName" and "badcirclelibName" detected: mylibName -> badcirclelibName -> anotherlibName -> mylibName Circular file chain: - libs/mylib/src/main.ts - libs/badcirclelib/src/main.ts - [libs/anotherlib/src/main.ts,libs/anotherlib/src/index.ts]`; - expect(failures.length).toEqual(2); - expect(failures[0].message).toEqual(message); - expect(failures[1].message).toEqual(message); + expect(failures.length).toEqual(2); + expect(failures[0].message).toEqual(message); + expect(failures[1].message).toEqual(message); + }); }); describe('buildable library imports', () => { diff --git a/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts index 16b92aa8c2e07f..5ef6ccabbd598c 100644 --- a/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts +++ b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts @@ -15,13 +15,14 @@ import { MappedProjectGraph, hasBannedImport, isDirectDependency, + getTargetProjectBasedOnRelativeImport, } from '@nrwl/workspace/src/utils/runtime-lint-utils'; import { AST_NODE_TYPES, TSESTree, } from '@typescript-eslint/experimental-utils'; import { createESLintRule } from '../utils/create-eslint-rule'; -import { normalizePath } from '@nrwl/devkit'; +import { joinPathFragments, normalizePath } from '@nrwl/devkit'; import { ProjectType, readCachedProjectGraph, @@ -35,6 +36,15 @@ import { import { isRelativePath } from '@nrwl/workspace/src/utilities/fileutils'; import { isSecondaryEntrypoint as isAngularSecondaryEntrypoint } from '../utils/angular'; import * as chalk from 'chalk'; +import { existsSync, readFileSync } from 'fs'; +import { findNodes } from '@nrwl/workspace/src/utilities/typescript'; +import ts = require('typescript'); +import { basename, dirname, join, relative, resolve } from 'path'; +import { + getBarrelEntryPointByImportScope, + getBarrelEntryPointProjectNode, + getRelativeImportPath, +} from '../utils/ast-utils'; type Options = [ { @@ -223,6 +233,90 @@ export default createESLintRule({ data: { npmScope, }, + fix(fixer) { + const targetImportProject = getTargetProjectBasedOnRelativeImport( + imp, + projectPath, + projectGraph, + sourceFilePath + ); + + if (targetImportProject) { + const indexTsPaths = + getBarrelEntryPointProjectNode(targetImportProject); + + const imports = (node as any).specifiers.map( + (specifier) => specifier.imported.name + ); + + if (indexTsPaths && indexTsPaths.length > 0) { + // process each potential entry point and try to find the imports + const importsToRemap = []; + + for (const entryPointPath of indexTsPaths) { + for (const importMember of imports) { + const importPath = getRelativeImportPath( + importMember, + entryPointPath.path, + sourceProject.data.sourceRoot + ); + + if (importPath) { + importsToRemap.push({ + member: importMember, + importScope: entryPointPath.importScope, + }); + } else { + // we cannot remap, so leave it as is + importsToRemap.push({ + member: importMember, + importScope: imp, + }); + } + } + } + + // group imports together, like having "import { Foo, Bar } from './foo'" + // instead of individual ones + const importsToRemapGrouped = importsToRemap.reduce( + (acc, curr) => { + const existing = acc.find( + (i) => + i.importScope === curr.importScope && + i.member !== curr.member + ); + if (existing) { + if (existing.member) { + existing.member += `, ${curr.member}`; + } + } else { + acc.push({ + importScope: curr.importScope, + member: curr.member, + }); + } + return acc; + }, + [] + ); + + // create the new import expressions + const adjustedRelativeImports = importsToRemapGrouped + .map( + (entry) => + `import { ${entry.member} } from '${entry.importScope}';` + ) + .join('\n'); + + if (adjustedRelativeImports !== '') { + return fixer.replaceTextRange( + node.range, + adjustedRelativeImports + ); + } + } + } + }, }); return; } @@ -256,6 +350,86 @@ export default createESLintRule({ data: { imp, }, + fix(fixer) { + // imported JS functions to remap + const imports = (node.source.parent as any).specifiers.map( + (specifier) => specifier.imported.name + ); + + // imp is equal to @myorg/someproject + const indexTsPaths = getBarrelEntryPointByImportScope(imp); + if (indexTsPaths && indexTsPaths.length > 0) { + // process each potential entry point and try to find the imports + const importsToRemap = []; + + for (const entryPointPath of indexTsPaths) { + for (const importMember of imports) { + const importPath = getRelativeImportPath( + importMember, + entryPointPath, + sourceProject.data.sourceRoot + ); + if (importPath) { + // resolve the import path + let importPathResolved = relative( + dirname(context.getFilename()), + dirname(importPath) + ); + + // if the string is empty, it's the current file + importPathResolved = + importPathResolved === '' + ? `./${basename(importPath)}` + : joinPathFragments( + importPathResolved, + basename(importPath) + ); + + importsToRemap.push({ + member: importMember, + relativePath: importPathResolved.replace('.ts', ''), + }); + } + } + } + + // group imports from the same relative path + const importsToRemapGrouped = importsToRemap.reduce( + (acc, curr) => { + const existing = acc.find( + (i) => + i.relativePath === curr.relativePath && + i.member !== curr.member + ); + if (existing) { + if (existing.member) { + existing.member += `, ${curr.member}`; + } + } else { + acc.push({ + relativePath: curr.relativePath, + member: curr.member, + }); + } + return acc; + }, + [] + ); + + const adjustedRelativeImports = importsToRemapGrouped + .map( + (entry) => + `import { ${entry.member} } from '${entry.relativePath}';` + ) + .join('\n'); + if (adjustedRelativeImports !== '') { + return fixer.replaceTextRange( + node.range, + adjustedRelativeImports + ); + } + } + }, }); } return; diff --git a/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries__self-circular.spec.ts b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries__self-circular.spec.ts new file mode 100644 index 00000000000000..ec65bad24ed738 --- /dev/null +++ b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries__self-circular.spec.ts @@ -0,0 +1,143 @@ +import type { FileData, ProjectGraph } from '@nrwl/devkit'; +import { + DependencyType, + ProjectType, +} from '@nrwl/workspace/src/core/project-graph'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import * as parser from '@typescript-eslint/parser'; +import { vol } from 'memfs'; +import enforceModuleBoundaries, { + RULE_NAME as enforceModuleBoundariesRuleName, +} from './enforce-module-boundaries'; +import { TargetProjectLocator } from '@nrwl/workspace/src/core/target-project-locator'; +import { mapProjectGraphFiles } from '@nrwl/workspace/src/utils/runtime-lint-utils'; +jest.mock('fs', () => require('memfs').fs); +jest.mock('@nrwl/tao/src/utils/app-root', () => ({ + appRootPath: '/root', +})); + +const tsconfig = { + compilerOptions: { + baseUrl: '.', + paths: { + '@mycompany/mylib': ['libs/mylib/src/index.ts'], + }, + types: ['node'], + }, + exclude: ['**/*.spec.ts'], + include: ['**/*.ts'], +}; + +const fileSys = { + './libs/mylib/src/index.ts': '', + './tsconfig.base.json': JSON.stringify(tsconfig), +}; + +describe('Enforce Module Boundaries - noSelfCircularDependencies (eslint)', () => { + beforeEach(() => { + vol.fromJSON(fileSys, '/root'); + }); + + it('should ignore detected absolute path within project if allowCircularSelfDependency flag is set', () => { + const failures = runRule( + { + allowCircularSelfDependency: true, + }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import '@mycompany/mylib'; + import('@mycompany/mylib'); + `, + { + nodes: { + mylibName: { + name: 'mylibName', + type: ProjectType.lib, + data: { + root: 'libs/mylib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [createFile(`libs/mylib/src/main.ts`)], + }, + }, + anotherlibName: { + name: 'anotherlibName', + type: ProjectType.lib, + data: { + root: 'libs/anotherlib', + tags: [], + implicitDependencies: [], + architect: {}, + files: [createFile(`libs/anotherlib/src/main.ts`)], + }, + }, + myappName: { + name: 'myappName', + type: ProjectType.app, + data: { + root: 'apps/myapp', + tags: [], + implicitDependencies: [], + architect: {}, + files: [createFile(`apps/myapp/src/index.ts`)], + }, + }, + }, + dependencies: { + mylibName: [ + { + source: 'mylibName', + target: 'anotherlibName', + type: DependencyType.static, + }, + ], + }, + } + ); + + expect(failures.length).toBe(0); + }); +}); + +const linter = new TSESLint.Linter(); +const baseConfig = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2018 as const, + sourceType: 'module' as const, + }, + rules: { + [enforceModuleBoundariesRuleName]: 'error', + }, +}; +linter.defineParser('@typescript-eslint/parser', parser); +linter.defineRule(enforceModuleBoundariesRuleName, enforceModuleBoundaries); + +function createFile(f: string, deps?: string[]): FileData { + return { file: f, hash: '', ...(deps && { deps }) }; +} + +function runRule( + ruleArguments: any, + contentPath: string, + content: string, + projectGraph: ProjectGraph +): TSESLint.Linter.LintMessage[] { + (global as any).projectPath = `${process.cwd()}/proj`; + (global as any).npmScope = 'mycompany'; + (global as any).projectGraph = mapProjectGraphFiles(projectGraph); + (global as any).targetProjectLocator = new TargetProjectLocator( + projectGraph.nodes, + projectGraph.externalNodes + ); + + const config = { + ...baseConfig, + rules: { + [enforceModuleBoundariesRuleName]: ['error', ruleArguments], + }, + }; + + return linter.verify(content, config as any, contentPath); +} diff --git a/packages/eslint-plugin-nx/src/utils/ast-utils.ts b/packages/eslint-plugin-nx/src/utils/ast-utils.ts new file mode 100644 index 00000000000000..beeda0c03ddcf5 --- /dev/null +++ b/packages/eslint-plugin-nx/src/utils/ast-utils.ts @@ -0,0 +1,233 @@ +import { joinPathFragments } from '@nrwl/devkit'; +import { findNodes } from '@nrwl/workspace/src/utilities/typescript'; +import { MappedProjectGraphNode } from '@nrwl/workspace/src/utils/runtime-lint-utils'; +import { existsSync, readFileSync } from 'fs'; +import { dirname } from 'path'; +import ts = require('typescript'); + +/** + * + * @param importScope like `@myorg/somelib` + * @returns + */ +export function getBarrelEntryPointByImportScope( + importScope: string +): string[] | null { + // read tsconfig.base.json + const tsConfigBase = JSON.parse( + readFileSync('tsconfig.base.json').toString('utf-8') + ); + + if ( + tsConfigBase && + tsConfigBase.compilerOptions && + tsConfigBase.compilerOptions.paths + ) { + const entryPoints = tsConfigBase.compilerOptions.paths[importScope]; + if (entryPoints) { + return entryPoints; + } + } + return null; +} + +export function getBarrelEntryPointProjectNode( + importScope: MappedProjectGraphNode +): { path: string; importScope: string }[] | null { + // read tsconfig.base.json + const tsConfigBase = JSON.parse( + readFileSync('tsconfig.base.json').toString('utf-8') + ); + + if ( + tsConfigBase && + tsConfigBase.compilerOptions && + tsConfigBase.compilerOptions.paths + ) { + const potentialEntryPoints = Object.keys(tsConfigBase.compilerOptions.paths) + .filter((entry) => { + const sourceFolderPaths = tsConfigBase.compilerOptions.paths[entry]; + return sourceFolderPaths.some((sourceFolderPath) => { + return sourceFolderPath.includes(importScope.data.root); + }); + }) + .map((entry) => + tsConfigBase.compilerOptions.paths[entry].map((x) => ({ + path: x, + importScope: entry, + })) + ); + + return potentialEntryPoints.flat(); + } + + return null; +} +function hasMemberExport(exportedMember, filePath) { + const fileContent = readFileSync(filePath, 'utf8'); + + // use the TypeScript AST to find the path to the file where exportedMember is defined + const sourceFile = ts.createSourceFile( + filePath, + fileContent, + ts.ScriptTarget.Latest, + true + ); + + // search whether there is already an export with our node + return ( + findNodes(sourceFile, ts.SyntaxKind.Identifier).filter( + (identifier: any) => identifier.text === exportedMember + ).length > 0 + ); +} + +export function getRelativeImportPath(exportedMember, filePath, basePath) { + const fileContent = readFileSync(filePath, 'utf8'); + + // use the TypeScript AST to find the path to the file where exportedMember is defined + const sourceFile = ts.createSourceFile( + filePath, + fileContent, + ts.ScriptTarget.Latest, + true + ); + + // Search in the current file whether there's an export already! + const memberNodes = findNodes(sourceFile, ts.SyntaxKind.Identifier).filter( + (identifier: any) => identifier.text === exportedMember + ); + + let hasExport = false; + for (const memberNode of memberNodes || []) { + if (memberNode) { + // recursively navigate upwards to find the ExportKey modifier + let parent = memberNode; + do { + parent = parent.parent; + if (parent) { + // if we are inside a parameter list or decorator or param assignment + // then this is not what we're searching for, so break :) + if ( + parent.kind === ts.SyntaxKind.Parameter || + parent.kind === ts.SyntaxKind.PropertyAccessExpression || + parent.kind === ts.SyntaxKind.TypeReference || + parent.kind === ts.SyntaxKind.HeritageClause || + parent.kind === ts.SyntaxKind.Decorator + ) { + hasExport = false; + break; + } + + // if our identifier is within an ExportDeclaration but is not just + // a re-export of some other module, we're good + if ( + parent.kind === ts.SyntaxKind.ExportDeclaration && + !(parent as any).moduleSpecifier + ) { + hasExport = true; + break; + } + + if ( + parent.modifiers && + parent.modifiers.find( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ) + ) { + /** + * if we get to a function export declaration we need to verify whether the + * exported function is actually the member we are searching for. Otherwise + * we might end up finding a function that just uses our searched identifier + * internally. + * + * Example: assume we try to find a constant member: `export const SOME_CONSTANT = 'bla'` + * + * Then we might end up in a file that uses it like + * + * import { SOME_CONSTANT } from '@myorg/samelib' + * + * export function someFunction() { + * return `Hi, ${SOME_CONSTANT}` + * } + * + * We want to avoid accidentally picking the someFunction export since we're searching upwards + * starting from `SOME_CONSTANT` identifier usages. + */ + if (parent.kind === ts.SyntaxKind.FunctionDeclaration) { + const parentName = (parent as any)?.name?.text; + if (parentName === exportedMember) { + hasExport = true; + break; + } + } else { + hasExport = true; + break; + } + } + } + } while (!!parent); + } + + if (hasExport) { + break; + } + } + + if (hasExport) { + // we found the file, now grab the path + return filePath; + } + + // if we didn't find an export, let's try to follow + // all export declarations and see whether any of those + // exports the node we're searching for + const exportDeclarations = findNodes( + sourceFile, + ts.SyntaxKind.ExportDeclaration + ) as ts.ExportDeclaration[]; + for (const exportDeclaration of exportDeclarations) { + if ((exportDeclaration as any).moduleSpecifier) { + // verify whether the export declaration we're looking at is a named export + // cause in that case we need to check whether our searched member is + // part of the exports + if ( + exportDeclaration.exportClause && + findNodes(exportDeclaration, ts.SyntaxKind.Identifier).filter( + (identifier: any) => identifier.text === exportedMember + ).length === 0 + ) { + continue; + } + + const modulePath = (exportDeclaration as any).moduleSpecifier.text; + + let moduleFilePath = joinPathFragments( + './', + dirname(filePath), + `${modulePath}.ts` + ); + if (!existsSync(moduleFilePath)) { + // might be a index.ts + moduleFilePath = joinPathFragments( + './', + dirname(filePath), + `${modulePath}/index.ts` + ); + } + + if (hasMemberExport(exportedMember, moduleFilePath)) { + const foundFilePath = getRelativeImportPath( + exportedMember, + moduleFilePath, + basePath + ); + if (foundFilePath) { + return foundFilePath; + } + } + } + } + + return null; +} diff --git a/packages/workspace/src/utils/runtime-lint-utils.ts b/packages/workspace/src/utils/runtime-lint-utils.ts index 212a161b95a4f2..56f99c81e9cb4c 100644 --- a/packages/workspace/src/utils/runtime-lint-utils.ts +++ b/packages/workspace/src/utils/runtime-lint-utils.ts @@ -71,20 +71,34 @@ export function isRelative(s: string) { return s.startsWith('.'); } -export function isRelativeImportIntoAnotherProject( +export function getTargetProjectBasedOnRelativeImport( imp: string, projectPath: string, projectGraph: MappedProjectGraph, - sourceFilePath: string, - sourceProject: ProjectGraphNode -): boolean { + sourceFilePath: string +) { if (!isRelative(imp)) return false; const targetFile = normalizePath( path.resolve(path.join(projectPath, path.dirname(sourceFilePath)), imp) ).substring(projectPath.length + 1); - const targetProject = findTargetProject(projectGraph, targetFile); + return findTargetProject(projectGraph, targetFile); +} + +export function isRelativeImportIntoAnotherProject( + imp: string, + projectPath: string, + projectGraph: MappedProjectGraph, + sourceFilePath: string, + sourceProject: ProjectGraphNode +): boolean { + const targetProject = getTargetProjectBasedOnRelativeImport( + imp, + projectPath, + projectGraph, + sourceFilePath + ); return sourceProject && targetProject && sourceProject !== targetProject; }