diff --git a/package.json b/package.json index 91e0d01b63..e977f969b8 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@types/webpack": "^4.4.24", "@types/yargs": "^11.0.0", "@typescript-eslint/eslint-plugin": "2.0.0-alpha.4", + "@typescript-eslint/experimental-utils": "2.0.0-alpha.4", "@typescript-eslint/parser": "2.0.0-alpha.4", "angular": "1.6.6", "app-root-path": "^2.0.1", diff --git a/packages/eslint-plugin-nx/package.json b/packages/eslint-plugin-nx/package.json new file mode 100644 index 0000000000..a756960d48 --- /dev/null +++ b/packages/eslint-plugin-nx/package.json @@ -0,0 +1,37 @@ +{ + "name": "@nrwl/eslint-plugin-nx", + "version": "0.0.1", + "description": "ESLint Plugin for Nx", + "repository": { + "type": "git", + "url": "git+https://github.com/nrwl/nx.git" + }, + "keywords": [ + "Monorepo", + "Web", + "Lint", + "ESLint", + "CLI" + ], + "files": [ + "src", + "package.json", + "README.md", + "LICENSE" + ], + "main": "src/index.js", + "types": "src/index.d.ts", + "author": "Victor Savkin", + "license": "MIT", + "bugs": { + "url": "https://github.com/nrwl/nx/issues" + }, + "homepage": "https://nx.dev", + "peerDependencies": { + "@nrwl/workspace": "*", + "@typescript-eslint/parser": "^2.0.0-alpha.4" + }, + "dependencies": { + "@typescript-eslint/experimental-utils": "2.0.0-alpha.4" + } +} diff --git a/packages/eslint-plugin-nx/src/index.ts b/packages/eslint-plugin-nx/src/index.ts new file mode 100644 index 0000000000..9aec075818 --- /dev/null +++ b/packages/eslint-plugin-nx/src/index.ts @@ -0,0 +1,9 @@ +import enforceModuleBoundaries, { + RULE_NAME as enforceModuleBoundariesRuleName +} from './rules/enforce-module-boundaries'; + +module.exports = { + rules: { + [enforceModuleBoundariesRuleName]: enforceModuleBoundaries + } +}; diff --git a/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts new file mode 100644 index 0000000000..325b73989a --- /dev/null +++ b/packages/eslint-plugin-nx/src/rules/enforce-module-boundaries.ts @@ -0,0 +1,250 @@ +import { ProjectType } from '@nrwl/workspace/src/command-line/affected-apps'; +import { readDependencies } from '@nrwl/workspace/src/command-line/deps-calculator'; +import { + getProjectNodes, + normalizedProjectRoot, + readNxJson, + readWorkspaceJson +} from '@nrwl/workspace/src/command-line/shared'; +import { appRootPath } from '@nrwl/workspace/src/utils/app-root'; +import { + DepConstraint, + findConstraintsFor, + findProjectUsingImport, + findSourceProject, + getSourceFilePath, + hasNoneOfTheseTags, + isAbsoluteImportIntoAnotherProject, + isCircular, + isRelativeImportIntoAnotherProject, + matchImportWithWildcard, + onlyLoadChildren +} from '@nrwl/workspace/src/utils/runtime-lint-utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createESLintRule } from '../utils/create-eslint-rule'; + +type Options = [ + { + allow: string[]; + depConstraints: DepConstraint[]; + } +]; +export type MessageIds = + | 'noRelativeOrAbsoluteImportsAcrossLibraries' + | 'noCircularDependencies' + | 'noImportsOfApps' + | 'noDeepImportsIntoLibraries' + | 'noImportsOfLazyLoadedLibraries' + | 'projectWithoutTagsCannotHaveDependencies' + | 'tagConstraintViolation'; +export const RULE_NAME = 'enforce-module-boundaries'; + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: `Ensure that module boundaries are respected within the monorepo`, + category: 'Best Practices', + recommended: 'error' + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + allow: [{ type: 'string' }], + depConstraints: [ + { + type: 'object', + properties: { + sourceTag: { type: 'string' }, + onlyDependOnLibsWithTags: [{ type: 'string' }] + }, + additionalProperties: false + } + ] + }, + additionalProperties: false + } + ], + messages: { + noRelativeOrAbsoluteImportsAcrossLibraries: `Library imports must start with @{{npmScope}}/`, + noCircularDependencies: `Circular dependency between "{{sourceProjectName}}" and "{{targetProjectName}}" detected`, + noImportsOfApps: 'Imports of apps are forbidden', + noDeepImportsIntoLibraries: 'Deep imports into libraries are forbidden', + noImportsOfLazyLoadedLibraries: `Imports of lazy-loaded libraries are forbidden`, + projectWithoutTagsCannotHaveDependencies: `A project without tags cannot depend on any libraries`, + tagConstraintViolation: `A project tagged with "{{sourceTag}}" can only depend on libs tagged with {{allowedTags}}` + } + }, + defaultOptions: [ + { + allow: [], + depConstraints: [] + } + ], + create(context, [{ allow, depConstraints }]) { + /** + * Globally cached info about workspace + */ + const projectPath = (global as any).projectPath || appRootPath; + if (!(global as any).projectNodes) { + const workspaceJson = readWorkspaceJson(); + const nxJson = readNxJson(); + (global as any).npmScope = nxJson.npmScope; + (global as any).projectNodes = getProjectNodes(workspaceJson, nxJson); + (global as any).deps = readDependencies( + (global as any).npmScope, + (global as any).projectNodes + ); + } + const npmScope = (global as any).npmScope; + const projectNodes = (global as any).projectNodes; + const deps = (global as any).deps; + + projectNodes.sort((a, b) => { + if (!a.root) return -1; + if (!b.root) return -1; + return a.root.length > b.root.length ? -1 : 1; + }); + + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + const imp = (node.source as TSESTree.Literal).value as string; + + const sourceFilePath = getSourceFilePath( + context.getFilename(), + projectPath + ); + + // whitelisted import + if (allow.some(a => matchImportWithWildcard(a, imp))) { + return; + } + + // check for relative and absolute imports + if ( + isRelativeImportIntoAnotherProject( + imp, + projectPath, + projectNodes, + sourceFilePath + ) || + isAbsoluteImportIntoAnotherProject(imp) + ) { + context.report({ + node, + messageId: 'noRelativeOrAbsoluteImportsAcrossLibraries', + data: { + npmScope + } + }); + return; + } + + // check constraints between libs and apps + if (imp.startsWith(`@${npmScope}/`)) { + // we should find the name + const sourceProject = findSourceProject(projectNodes, sourceFilePath); + // findProjectUsingImport to take care of same prefix + const targetProject = findProjectUsingImport( + projectNodes, + npmScope, + imp + ); + + // something went wrong => return. + if (!sourceProject || !targetProject) { + return; + } + + // check for circular dependency + if (isCircular(deps, sourceProject, targetProject)) { + context.report({ + node, + messageId: 'noCircularDependencies', + data: { + sourceProjectName: sourceProject.name, + targetProjectName: targetProject.name + } + }); + return; + } + + // same project => allow + if (sourceProject === targetProject) { + return; + } + + // cannot import apps + if (targetProject.type !== ProjectType.lib) { + context.report({ + node, + messageId: 'noImportsOfApps' + }); + return; + } + + // deep imports aren't allowed + if (imp !== `@${npmScope}/${normalizedProjectRoot(targetProject)}`) { + context.report({ + node, + messageId: 'noDeepImportsIntoLibraries' + }); + return; + } + + // if we import a library using loadChildren, we should not import it using es6imports + if ( + onlyLoadChildren(deps, sourceProject.name, targetProject.name, []) + ) { + context.report({ + node, + messageId: 'noImportsOfLazyLoadedLibraries' + }); + return; + } + + // check that dependency constraints are satisfied + if (depConstraints.length > 0) { + const constraints = findConstraintsFor( + depConstraints, + sourceProject + ); + // when no constrains found => error. Force the user to provision them. + if (constraints.length === 0) { + context.report({ + node, + messageId: 'projectWithoutTagsCannotHaveDependencies' + }); + return; + } + + for (let constraint of constraints) { + if ( + hasNoneOfTheseTags( + targetProject, + constraint.onlyDependOnLibsWithTags || [] + ) + ) { + const allowedTags = constraint.onlyDependOnLibsWithTags + .map(s => `"${s}"`) + .join(', '); + context.report({ + node, + messageId: 'tagConstraintViolation', + data: { + sourceTag: constraint.sourceTag, + allowedTags + } + }); + return; + } + } + } + } + } + }; + } +}); diff --git a/packages/eslint-plugin-nx/src/utils/create-eslint-rule.ts b/packages/eslint-plugin-nx/src/utils/create-eslint-rule.ts new file mode 100644 index 0000000000..c72c352363 --- /dev/null +++ b/packages/eslint-plugin-nx/src/utils/create-eslint-rule.ts @@ -0,0 +1,3 @@ +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; + +export const createESLintRule = ESLintUtils.RuleCreator(() => ``); diff --git a/packages/eslint-plugin-nx/tests/rules/enforce-module-boundaries.spec.ts b/packages/eslint-plugin-nx/tests/rules/enforce-module-boundaries.spec.ts new file mode 100644 index 0000000000..b2052943fe --- /dev/null +++ b/packages/eslint-plugin-nx/tests/rules/enforce-module-boundaries.spec.ts @@ -0,0 +1,834 @@ +import { + ProjectNode, + ProjectType +} from '@nrwl/workspace/src/command-line/affected-apps'; +import { + Dependency, + DependencyType +} from '@nrwl/workspace/src/command-line/deps-calculator'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import * as parser from '@typescript-eslint/parser'; +import * as fs from 'fs'; +import enforceModuleBoundaries, { + RULE_NAME as enforceModuleBoundariesRuleName +} from '../../src/rules/enforce-module-boundaries'; + +describe('Enforce Module Boundaries', () => { + beforeEach(() => { + spyOn(fs, 'writeFileSync'); + }); + + it('should not error when everything is in order', () => { + const failures = runRule( + { allow: ['@mycompany/mylib/deep'] }, + `${process.cwd()}/proj/apps/myapp/src/main.ts`, + ` + import '@mycompany/mylib'; + import '@mycompany/mylib/deep'; + import '../blah'; + `, + [ + { + name: 'myappName', + root: 'libs/myapp', + type: ProjectType.app, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`apps/myapp/src/main.ts`, `apps/myapp/blah.ts`], + fileMTimes: { + 'apps/myapp/src/main.ts': 0, + 'apps/myapp/blah.ts': 1 + } + }, + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/index.ts`, `libs/mylib/src/deep.ts`], + fileMTimes: { + 'apps/mylib/src/index.ts': 0, + 'apps/mylib/src/deep.ts': 1 + } + } + ] + ); + + expect(failures.length).toEqual(0); + }); + + it('should handle multiple projects starting with the same prefix properly', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/apps/myapp/src/main.ts`, + ` + import '@mycompany/myapp2/mylib'; + `, + [ + { + name: 'myappName', + root: 'libs/myapp', + type: ProjectType.app, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`apps/myapp/src/main.ts`, `apps/myapp/src/blah.ts`], + fileMTimes: { + 'apps/myapp/src/main.ts': 0, + 'apps/myapp/src/blah.ts': 1 + } + }, + { + name: 'myapp2Name', + root: 'libs/myapp2', + type: ProjectType.app, + tags: [], + implicitDependencies: [], + architect: {}, + files: [], + fileMTimes: {} + }, + { + name: 'myapp2-mylib', + root: 'libs/myapp2/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: ['libs/myapp2/mylib/src/index.ts'], + fileMTimes: { + 'libs/myapp2/mylib/src/index.ts': 1 + } + } + ] + ); + + expect(failures.length).toEqual(0); + }); + + describe('depConstraints', () => { + const projectNodes: ProjectNode[] = [ + { + name: 'apiName', + root: 'libs/api', + type: ProjectType.lib, + tags: ['api', 'domain1'], + implicitDependencies: [], + architect: {}, + files: [`libs/api/src/index.ts`], + fileMTimes: { + 'libs/api/src/index.ts': 1 + } + }, + { + name: 'implName', + root: 'libs/impl', + type: ProjectType.lib, + tags: ['impl', 'domain1'], + implicitDependencies: [], + architect: {}, + files: [`libs/impl/src/index.ts`], + fileMTimes: { + 'libs/impl/src/index.ts': 1 + } + }, + { + name: 'impl2Name', + root: 'libs/impl2', + type: ProjectType.lib, + tags: ['impl', 'domain1'], + implicitDependencies: [], + architect: {}, + files: [`libs/impl2/src/index.ts`], + fileMTimes: { + 'libs/impl2/src/index.ts': 1 + } + }, + { + name: 'impl-domain2Name', + root: 'libs/impl-domain2', + type: ProjectType.lib, + tags: ['impl', 'domain2'], + implicitDependencies: [], + architect: {}, + files: [`libs/impl-domain2/src/index.ts`], + fileMTimes: { + 'libs/impl-domain2/src/index.ts': 1 + } + }, + { + name: 'impl-both-domainsName', + root: 'libs/impl-both-domains', + type: ProjectType.lib, + tags: ['impl', 'domain1', 'domain2'], + implicitDependencies: [], + architect: {}, + files: [`libs/impl-both-domains/src/index.ts`], + fileMTimes: { + 'libs/impl-both-domains/src/index.ts': 1 + } + }, + { + name: 'untaggedName', + root: 'libs/untagged', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/untagged/src/index.ts`], + fileMTimes: { + 'libs/untagged/src/index.ts': 1 + } + } + ]; + + const depConstraints = { + depConstraints: [ + { sourceTag: 'api', onlyDependOnLibsWithTags: ['api'] }, + { sourceTag: 'impl', onlyDependOnLibsWithTags: ['api', 'impl'] }, + { sourceTag: 'domain1', onlyDependOnLibsWithTags: ['domain1'] }, + { sourceTag: 'domain2', onlyDependOnLibsWithTags: ['domain2'] } + ] + }; + + it('should error when the target library does not have the right tag', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/api/src/index.ts`, + ` + import '@mycompany/impl'; + `, + projectNodes + ); + + expect(failures[0].message).toEqual( + 'A project tagged with "api" can only depend on libs tagged with "api"' + ); + }); + + it('should error when the target library is untagged', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/api/src/index.ts`, + ` + import '@mycompany/untagged'; + `, + projectNodes + ); + + expect(failures[0].message).toEqual( + 'A project tagged with "api" can only depend on libs tagged with "api"' + ); + }); + + it('should error when the source library is untagged', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/untagged/src/index.ts`, + ` + import '@mycompany/api'; + `, + projectNodes + ); + + expect(failures[0].message).toEqual( + 'A project without tags cannot depend on any libraries' + ); + }); + + it('should check all tags', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/impl/src/index.ts`, + ` + import '@mycompany/impl-domain2'; + `, + projectNodes + ); + + expect(failures[0].message).toEqual( + 'A project tagged with "domain1" can only depend on libs tagged with "domain1"' + ); + }); + + it('should allow a domain1 project to depend on a project that is tagged with domain1 and domain2', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/impl/src/index.ts`, + ` + import '@mycompany/impl-both-domains'; + `, + projectNodes + ); + + expect(failures.length).toEqual(0); + }); + + it('should allow a domain1/domain2 project depend on domain1', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/impl-both-domain/src/index.ts`, + ` + import '@mycompany/impl'; + `, + projectNodes + ); + + expect(failures.length).toEqual(0); + }); + + it('should not error when the constraints are satisfied', () => { + const failures = runRule( + depConstraints, + `${process.cwd()}/proj/libs/impl/src/index.ts`, + ` + import '@mycompany/impl2'; + `, + projectNodes + ); + + expect(failures.length).toEqual(0); + }); + + it('should support wild cards', () => { + const failures = runRule( + { + depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }] + }, + `${process.cwd()}/proj/libs/api/src/index.ts`, + ` + import '@mycompany/impl'; + `, + projectNodes + ); + + expect(failures.length).toEqual(0); + }); + }); + + describe('relative imports', () => { + it('should not error when relatively importing the same library', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "../other"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`, `libs/mylib/other.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1, + 'libs/mylib/other.ts': 1 + } + } + ] + ); + expect(failures.length).toEqual(0); + }); + + it('should not error when relatively importing the same library (index file)', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "../other"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`, `libs/mylib/other/index.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1, + 'libs/mylib/other/index.ts': 1 + } + } + ] + ); + expect(failures.length).toEqual(0); + }); + + it('should error when relatively importing another library', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "../../other"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'otherName', + root: 'libs/other', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: ['libs/other/src/index.ts'], + fileMTimes: { + 'libs/other/src/main.ts': 1 + } + } + ] + ); + expect(failures[0].message).toEqual( + 'Library imports must start with @mycompany/' + ); + }); + + it('should error when relatively importing the src directory of another library', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "../../other/src"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'otherName', + root: 'libs/other', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: ['libs/other/src/index.ts'], + fileMTimes: { + 'libs/other/src/main.ts': 1 + } + } + ] + ); + expect(failures[0].message).toEqual( + 'Library imports must start with @mycompany/' + ); + }); + }); + + it('should error on absolute imports into libraries without using the npm scope', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "libs/src/other.ts"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`, `libs/mylib/src/other.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1, + 'libs/mylib/src/other/index.ts': 1 + } + } + ] + ); + + expect(failures.length).toEqual(1); + expect(failures[0].message).toEqual( + 'Library imports must start with @mycompany/' + ); + }); + + it('should error about deep imports into libraries', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import "@mycompany/other/src/blah" + import "@mycompany/other/src/sublib/blah" + `, + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`, `libs/mylib/src/another-file.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1, + 'libs/mylib/src/another-file.ts': 1 + } + }, + { + name: 'otherName', + root: 'libs/other', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/other/src/blah.ts`], + fileMTimes: { + 'libs/other/src/blah.ts': 1 + } + }, + { + name: 'otherSublibName', + root: 'libs/other/sublib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/other/sublib/src/blah.ts`], + fileMTimes: { + 'libs/other/sublib/src/blah.ts': 1 + } + } + ] + ); + expect(failures[0].message).toEqual( + 'Deep imports into libraries are forbidden' + ); + expect(failures[1].message).toEqual( + 'Deep imports into libraries are forbidden' + ); + }); + + it('should not error about deep imports into library when fixed exception is set', () => { + const failures = runRule( + { allow: ['@mycompany/other/src/blah'] }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import "@mycompany/other/src/blah" + `, + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`, `libs/mylib/src/another-file.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1, + 'libs/mylib/src/another-file.ts': 1 + } + }, + { + name: 'otherName', + root: 'libs/other', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/other/src/blah.ts`], + fileMTimes: { + 'libs/other/src/blah.ts': 1 + } + } + ] + ); + expect(failures.length).toEqual(0); + }); + + it('should not error about deep imports into library when exception is specified with a wildcard', () => { + const failures = runRule( + { allow: ['@mycompany/other/*'] }, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + ` + import "@mycompany/other/src/blah" + `, + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`, `libs/mylib/src/another-file.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1, + 'libs/mylib/src/another-file.ts': 1 + } + }, + { + name: 'otherName', + root: 'libs/other', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/other/src/blah.ts`], + fileMTimes: { + 'libs/other/src/blah.ts': 1 + } + } + ] + ); + expect(failures.length).toEqual(0); + }); + + it('should error on importing a lazy-loaded library', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "@mycompany/other";', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'otherName', + root: 'libs/other', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/other/index.ts`], + fileMTimes: { + 'libs/other/index.ts': 1 + } + } + ], + { + mylibName: [ + { projectName: 'otherName', type: DependencyType.loadChildren } + ] + } + ); + expect(failures[0].message).toEqual( + 'Imports of lazy-loaded libraries are forbidden' + ); + }); + + it('should error on importing an app', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "@mycompany/myapp"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'myappName', + root: 'apps/myapp', + type: ProjectType.app, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`apps/myapp/src/index.ts`], + fileMTimes: { + 'apps/myapp/src/index.ts': 1 + } + } + ] + ); + expect(failures[0].message).toEqual('Imports of apps are forbidden'); + }); + + it('should error when circular dependency detected', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/anotherlib/src/main.ts`, + 'import "@mycompany/mylib"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'anotherlibName', + root: 'libs/anotherlib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/anotherlib/src/main.ts`], + fileMTimes: { + 'libs/anotherlib/src/main.ts': 1 + } + }, + { + name: 'myappName', + root: 'apps/myapp', + type: ProjectType.app, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`apps/myapp/src/index.ts`], + fileMTimes: { + 'apps/myapp/src/index.ts': 1 + } + } + ], + { + mylibName: [ + { projectName: 'anotherlibName', type: DependencyType.es6Import } + ] + } + ); + expect(failures[0].message).toEqual( + 'Circular dependency between "anotherlibName" and "mylibName" detected' + ); + }); + + it('should error when circular dependency detected (indirect)', () => { + const failures = runRule( + {}, + `${process.cwd()}/proj/libs/mylib/src/main.ts`, + 'import "@mycompany/badcirclelib"', + [ + { + name: 'mylibName', + root: 'libs/mylib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/mylib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'anotherlibName', + root: 'libs/anotherlib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/anotherlib/src/main.ts`], + fileMTimes: { + 'libs/mylib/src/main.ts': 1 + } + }, + { + name: 'badcirclelibName', + root: 'libs/badcirclelib', + type: ProjectType.lib, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`libs/badcirclelib/src/main.ts`], + fileMTimes: { + 'libs/badcirclelib/src/main.ts': 1 + } + }, + { + name: 'myappName', + root: 'apps/myapp', + type: ProjectType.app, + tags: [], + implicitDependencies: [], + architect: {}, + files: [`apps/myapp/index.ts`], + fileMTimes: { + 'apps/myapp/index.ts': 1 + } + } + ], + { + mylibName: [ + { projectName: 'badcirclelibName', type: DependencyType.es6Import } + ], + badcirclelibName: [ + { projectName: 'anotherlibName', type: DependencyType.es6Import } + ], + anotherlibName: [ + { projectName: 'mylibName', type: DependencyType.es6Import } + ] + } + ); + expect(failures[0].message).toEqual( + 'Circular dependency between "mylibName" and "badcirclelibName" detected' + ); + }); +}); + +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 runRule( + ruleArguments: any, + contentPath: string, + content: string, + projectNodes: ProjectNode[], + deps: { [projectName: string]: Dependency[] } = {} +): TSESLint.Linter.LintMessage[] { + (global as any).projectPath = `${process.cwd()}/proj`; + (global as any).npmScope = 'mycompany'; + (global as any).projectNodes = projectNodes; + (global as any).deps = deps; + + const config = { + ...baseConfig, + rules: { + [enforceModuleBoundariesRuleName]: ['error', ruleArguments] + } + }; + + return linter.verifyAndFix(content, config as any, contentPath).messages; +} diff --git a/packages/eslint-plugin-nx/tests/test-helper.ts b/packages/eslint-plugin-nx/tests/test-helper.ts new file mode 100644 index 0000000000..d4d5644669 --- /dev/null +++ b/packages/eslint-plugin-nx/tests/test-helper.ts @@ -0,0 +1,74 @@ +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import * as path from 'path'; + +const parser = '@typescript-eslint/parser'; + +type RuleTesterConfig = Exclude & { + parser: typeof parser; +}; + +export class RuleTester extends TSESLint.RuleTester { + private filename: string | undefined = undefined; + + // as of eslint 6 you have to provide an absolute path to the parser + // but that's not as clean to type, this saves us trying to manually enforce + // that contributors require.resolve everything + constructor(options: RuleTesterConfig) { + super({ + ...options, + parser: require.resolve(options.parser) + }); + + if (options.parserOptions && options.parserOptions.project) { + this.filename = path.join(getFixturesRootDir(), 'file.ts'); + } + } + + // as of eslint 6 you have to provide an absolute path to the parser + // If you don't do that at the test level, the test will fail somewhat cryptically... + // This is a lot more explicit + run>( + name: string, + rule: TSESLint.RuleModule, + tests: TSESLint.RunTests + ): void { + const errorMessage = `Do not set the parser at the test level unless you want to use a parser other than ${parser}`; + + if (this.filename) { + tests.valid = tests.valid.map(test => { + if (typeof test === 'string') { + return { + code: test, + filename: this.filename + }; + } + return test; + }); + } + + tests.valid.forEach(test => { + if (typeof test !== 'string') { + if (test.parser === parser) { + throw new Error(errorMessage); + } + if (!test.filename) { + test.filename = this.filename; + } + } + }); + tests.invalid.forEach(test => { + if (test.parser === parser) { + throw new Error(errorMessage); + } + if (!test.filename) { + test.filename = this.filename; + } + }); + + super.run(name, rule, tests); + } +} + +function getFixturesRootDir() { + return path.join(process.cwd(), 'tests/fixtures/'); +} diff --git a/packages/workspace/src/schematics/shared-new/shared-new.ts b/packages/workspace/src/schematics/shared-new/shared-new.ts index eee8d348e9..198ae0dbf9 100644 --- a/packages/workspace/src/schematics/shared-new/shared-new.ts +++ b/packages/workspace/src/schematics/shared-new/shared-new.ts @@ -174,16 +174,14 @@ function addTasks(options: Schema) { new NodePackageInstallTask(options.directory) ); } - if (options.preset !== 'empty') { - const createPresetTask = context.addTask(new RunPresetTask(), [ - packageTask - ]); + const createPresetTask = context.addTask(new RunPresetTask(), [ + packageTask + ]); - presetInstallTask = context.addTask( - new NodePackageInstallTask(options.directory), - [createPresetTask] - ); - } + presetInstallTask = context.addTask( + new NodePackageInstallTask(options.directory), + [createPresetTask] + ); if (!options.skipGit) { const commit = typeof options.commit == 'object' diff --git a/packages/workspace/src/schematics/workspace/files/package.json__tmpl__ b/packages/workspace/src/schematics/workspace/files/package.json__tmpl__ index 7a8c73c71c..815a26d2fe 100644 --- a/packages/workspace/src/schematics/workspace/files/package.json__tmpl__ +++ b/packages/workspace/src/schematics/workspace/files/package.json__tmpl__ @@ -42,6 +42,7 @@ "dotenv": "6.2.0", "ts-node": "~7.0.0", "tslint": "~5.11.0", + "eslint": "<%= eslintVersion %>", "typescript": "<%= typescriptVersion %>", "prettier": "<%= prettierVersion %>" } diff --git a/packages/workspace/src/schematics/workspace/workspace.ts b/packages/workspace/src/schematics/workspace/workspace.ts index c0724c3257..c2630c63a8 100644 --- a/packages/workspace/src/schematics/workspace/workspace.ts +++ b/packages/workspace/src/schematics/workspace/workspace.ts @@ -15,6 +15,7 @@ import { angularCliVersion, prettierVersion, typescriptVersion, + eslintVersion, nxVersion } from '../../utils/versions'; @@ -39,6 +40,7 @@ export default function(options: Schema): Rule { nxCli: false, typescriptVersion, prettierVersion, + eslintVersion, // angular cli is used only when workspace schematics is added to angular cli angularCliVersion, ...(options as object), diff --git a/packages/workspace/src/tslint/nxEnforceModuleBoundariesRule.ts b/packages/workspace/src/tslint/nxEnforceModuleBoundariesRule.ts index 6cd31fd285..b647e2f90d 100644 --- a/packages/workspace/src/tslint/nxEnforceModuleBoundariesRule.ts +++ b/packages/workspace/src/tslint/nxEnforceModuleBoundariesRule.ts @@ -1,20 +1,29 @@ -import * as path from 'path'; import * as Lint from 'tslint'; import { IOptions } from 'tslint'; import * as ts from 'typescript'; +import { ProjectNode, ProjectType } from '../command-line/affected-apps'; +import { readDependencies } from '../command-line/deps-calculator'; import { getProjectNodes, normalizedProjectRoot, - readWorkspaceJson, - readNxJson + readNxJson, + readWorkspaceJson } from '../command-line/shared'; -import { ProjectNode, ProjectType } from '../command-line/affected-apps'; -import { - Dependency, - DependencyType, - readDependencies -} from '../command-line/deps-calculator'; import { appRootPath } from '../utils/app-root'; +import { + DepConstraint, + Deps, + findConstraintsFor, + findProjectUsingImport, + findSourceProject, + getSourceFilePath, + hasNoneOfTheseTags, + isAbsoluteImportIntoAnotherProject, + isCircular, + isRelativeImportIntoAnotherProject, + matchImportWithWildcard, + onlyLoadChildren +} from '../utils/runtime-lint-utils'; export class Rule extends Lint.Rules.AbstractRule { constructor( @@ -22,7 +31,7 @@ export class Rule extends Lint.Rules.AbstractRule { private readonly projectPath?: string, private readonly npmScope?: string, private readonly projectNodes?: ProjectNode[], - private readonly deps?: { [projectName: string]: Dependency[] } + private readonly deps?: Deps ) { super(options); if (!projectPath) { @@ -57,11 +66,6 @@ export class Rule extends Lint.Rules.AbstractRule { } } -type DepConstraint = { - sourceTag: string; - onlyDependOnLibsWithTags: string[]; -}; - class EnforceModuleBoundariesWalker extends Lint.RuleWalker { private readonly allow: string[]; private readonly depConstraints: DepConstraint[]; @@ -72,7 +76,7 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { private readonly projectPath: string, private readonly npmScope: string, private readonly projectNodes: ProjectNode[], - private readonly deps: { [projectName: string]: Dependency[] } + private readonly deps: Deps ) { super(sourceFile, options); @@ -104,8 +108,13 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { // check for relative and absolute imports if ( - this.isRelativeImportIntoAnotherProject(imp) || - this.isAbsoluteImportIntoAnotherProject(imp) + isRelativeImportIntoAnotherProject( + imp, + this.projectPath, + this.projectNodes, + getSourceFilePath(this.getSourceFile().fileName, this.projectPath) + ) || + isAbsoluteImportIntoAnotherProject(imp) ) { this.addFailureAt( node.getStart(), @@ -118,8 +127,16 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { // check constraints between libs and apps if (imp.startsWith(`@${this.npmScope}/`)) { // we should find the name - const sourceProject = this.findSourceProject(); - const targetProject = this.findProjectUsingImport(imp); // findProjectUsingImport to take care of same prefix + const sourceProject = findSourceProject( + this.projectNodes, + getSourceFilePath(this.getSourceFile().fileName, this.projectPath) + ); + // findProjectUsingImport to take care of same prefix + const targetProject = findProjectUsingImport( + this.projectNodes, + this.npmScope, + imp + ); // something went wrong => return. if (!sourceProject || !targetProject) { @@ -128,7 +145,7 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { } // check for circular dependency - if (this.isCircular(sourceProject, targetProject)) { + if (isCircular(this.deps, sourceProject, targetProject)) { const error = `Circular dependency between "${ sourceProject.name }" and "${targetProject.name}" detected`; @@ -163,7 +180,9 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { } // if we import a library using loadChildre, we should not import it using es6imports - if (this.onlyLoadChildren(sourceProject.name, targetProject.name, [])) { + if ( + onlyLoadChildren(this.deps, sourceProject.name, targetProject.name, []) + ) { this.addFailureAt( node.getStart(), node.getWidth(), @@ -174,7 +193,10 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { // check that dependency constraints are satisfied if (this.depConstraints.length > 0) { - const constraints = this.findConstraintsFor(sourceProject); + const constraints = findConstraintsFor( + this.depConstraints, + sourceProject + ); // when no constrains found => error. Force the user to provision them. if (constraints.length === 0) { this.addFailureAt( @@ -207,152 +229,4 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker { super.visitImportDeclaration(node); } - - private isCircular( - sourceProject: ProjectNode, - targetProject: ProjectNode - ): boolean { - if (!this.deps[targetProject.name]) return false; - return this.isDependingOn(targetProject.name, sourceProject.name); - } - - private isDependingOn( - sourceProjectName: string, - targetProjectName: string, - done: { [projectName: string]: boolean } = {} - ): boolean { - if (done[sourceProjectName]) return false; - if (!this.deps[sourceProjectName]) return false; - return this.deps[sourceProjectName] - .map(dep => - dep.projectName === targetProjectName - ? true - : this.isDependingOn(dep.projectName, targetProjectName, { - ...done, - [`${sourceProjectName}`]: true - }) - ) - .some(result => result); - } - - private onlyLoadChildren( - sourceProjectName: string, - targetProjectName: string, - visited: string[] - ) { - if (visited.indexOf(sourceProjectName) > -1) return false; - return ( - (this.deps[sourceProjectName] || []).filter(d => { - if (d.type !== DependencyType.loadChildren) return false; - if (d.projectName === targetProjectName) return true; - return this.onlyLoadChildren(d.projectName, targetProjectName, [ - ...visited, - sourceProjectName - ]); - }).length > 0 - ); - } - - private isRelativeImportIntoAnotherProject(imp: string): boolean { - if (!this.isRelative(imp)) return false; - - const targetFile = normalizePath( - path.resolve( - path.join(this.projectPath, path.dirname(this.getSourceFilePath())), - imp - ) - ).substring(this.projectPath.length + 1); - - const sourceProject = this.findSourceProject(); - const targetProject = this.findTargetProject(targetFile); - return sourceProject && targetProject && sourceProject !== targetProject; - } - - private getSourceFilePath() { - return this.getSourceFile().fileName.substring(this.projectPath.length + 1); - } - - private findSourceProject() { - const targetFile = removeExt(this.getSourceFilePath()); - return this.findProjectUsingFile(targetFile); - } - - private findTargetProject(targetFile: string) { - let targetProject = this.findProjectUsingFile(targetFile); - if (!targetProject) { - targetProject = this.findProjectUsingFile( - normalizePath(path.join(targetFile, 'index')) - ); - } - if (!targetProject) { - targetProject = this.findProjectUsingFile( - normalizePath(path.join(targetFile, 'src', 'index')) - ); - } - return targetProject; - } - - private findProjectUsingFile(file: string) { - return this.projectNodes.filter(n => containsFile(n.files, file))[0]; - } - - private findProjectUsingImport(imp: string) { - const unscopedImport = imp.substring(this.npmScope.length + 2); - return this.projectNodes.filter(n => { - const normalizedRoot = normalizedProjectRoot(n); - return ( - unscopedImport === normalizedRoot || - unscopedImport.startsWith(`${normalizedRoot}/`) - ); - })[0]; - } - - private isAbsoluteImportIntoAnotherProject(imp: string) { - return ( - imp.startsWith('libs/') || - imp.startsWith('/libs/') || - imp.startsWith('apps/') || - imp.startsWith('/apps/') - ); - } - - private isRelative(s: string) { - return s.startsWith('.'); - } - - private findConstraintsFor(sourceProject: ProjectNode) { - return this.depConstraints.filter(f => hasTag(sourceProject, f.sourceTag)); - } -} - -function hasNoneOfTheseTags(proj: ProjectNode, tags: string[]) { - return tags.filter(allowedTag => hasTag(proj, allowedTag)).length === 0; -} - -function hasTag(proj: ProjectNode, tag: string) { - return (proj.tags || []).indexOf(tag) > -1 || tag === '*'; -} - -function containsFile( - files: string[], - targetFileWithoutExtension: string -): boolean { - return !!files.filter(f => removeExt(f) === targetFileWithoutExtension)[0]; -} - -function removeExt(file: string): string { - return file.replace(/\.[^/.]+$/, ''); -} - -function normalizePath(osSpecificPath: string): string { - return osSpecificPath.split(path.sep).join('/'); -} - -function matchImportWithWildcard( - // This may or may not contain wildcards ("*") - allowableImport: string, - extractedImport: string -): boolean { - const regex = new RegExp('^' + allowableImport.split('*').join('.*') + '$'); - return regex.test(extractedImport); } diff --git a/packages/workspace/src/utils/lint.ts b/packages/workspace/src/utils/lint.ts index b38b3828e8..c3eba41802 100644 --- a/packages/workspace/src/utils/lint.ts +++ b/packages/workspace/src/utils/lint.ts @@ -10,7 +10,8 @@ import { offsetFromRoot } from './common'; import { eslintVersion, typescriptESLintVersion, - eslintConfigPrettierVersion + eslintConfigPrettierVersion, + nxVersion } from './versions'; export const enum Linter { @@ -75,6 +76,7 @@ export function addLintFiles( addDepsToPackageJson( {}, { + '@nrwl/eslint-plugin-nx': nxVersion, '@typescript-eslint/parser': typescriptESLintVersion, '@typescript-eslint/eslint-plugin': typescriptESLintVersion, eslint: eslintVersion, @@ -179,7 +181,7 @@ const globalESLint = ` "sourceType": "module", "project": "./tsconfig.json" }, - "plugins": ["@typescript-eslint"], + "plugins": ["@typescript-eslint", "@nrwl/nx"], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", @@ -190,7 +192,16 @@ const globalESLint = ` "rules": { "@typescript-eslint/explicit-member-accessibility": "off", "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-parameter-properties": "off" + "@typescript-eslint/no-parameter-properties": "off", + "@nrwl/nx/enforce-module-boundaries": [ + "error", + { + "allow": [], + "depConstraints": [ + { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } + ] + } + ] }, "overrides": [ { @@ -227,7 +238,7 @@ const globalESLint = ` // "version": "detect" // } // }, -// "plugins": ["@typescript-eslint", "import", "jsx-a11y", "react", "react-hooks"], +// "plugins": ["@typescript-eslint", "@nrwl/nx", "import", "jsx-a11y", "react", "react-hooks"], // "extends": [ // "eslint:recommended", // "plugin:@typescript-eslint/eslint-recommended", @@ -241,6 +252,15 @@ const globalESLint = ` // * https://github.com/facebook/create-react-app // */ // "rules": { +// "@nrwl/nx/enforce-module-boundaries": [ +// "error", +// { +// "allow": [], +// "depConstraints": [ +// { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } +// ] +// } +// ], // /** // * Standard ESLint rule configurations // * https://eslint.org/docs/rules diff --git a/packages/workspace/src/utils/runtime-lint-utils.ts b/packages/workspace/src/utils/runtime-lint-utils.ts new file mode 100644 index 0000000000..aa8cc6091f --- /dev/null +++ b/packages/workspace/src/utils/runtime-lint-utils.ts @@ -0,0 +1,181 @@ +import * as path from 'path'; +import { ProjectNode } from '../command-line/affected-apps'; +import { Dependency, DependencyType } from '../command-line/deps-calculator'; +import { normalizedProjectRoot } from '../command-line/shared'; + +export type Deps = { [projectName: string]: Dependency[] }; +export type DepConstraint = { + sourceTag: string; + onlyDependOnLibsWithTags: string[]; +}; + +export function hasNoneOfTheseTags(proj: ProjectNode, tags: string[]) { + return tags.filter(allowedTag => hasTag(proj, allowedTag)).length === 0; +} + +function hasTag(proj: ProjectNode, tag: string) { + return (proj.tags || []).indexOf(tag) > -1 || tag === '*'; +} + +function containsFile( + files: string[], + targetFileWithoutExtension: string +): boolean { + return !!files.filter(f => removeExt(f) === targetFileWithoutExtension)[0]; +} + +function removeExt(file: string): string { + return file.replace(/\.[^/.]+$/, ''); +} + +function normalizePath(osSpecificPath: string): string { + return osSpecificPath.split(path.sep).join('/'); +} + +export function matchImportWithWildcard( + // This may or may not contain wildcards ("*") + allowableImport: string, + extractedImport: string +): boolean { + const regex = new RegExp('^' + allowableImport.split('*').join('.*') + '$'); + return regex.test(extractedImport); +} + +export function isRelative(s: string) { + return s.startsWith('.'); +} + +export function isRelativeImportIntoAnotherProject( + imp: string, + projectPath: string, + projectNodes: ProjectNode[], + sourceFilePath: string +): boolean { + if (!isRelative(imp)) return false; + + const targetFile = normalizePath( + path.resolve(path.join(projectPath, path.dirname(sourceFilePath)), imp) + ).substring(projectPath.length + 1); + + const sourceProject = findSourceProject(projectNodes, sourceFilePath); + const targetProject = findTargetProject(projectNodes, targetFile); + return sourceProject && targetProject && sourceProject !== targetProject; +} + +export function findProjectUsingFile( + projectNodes: ProjectNode[], + file: string +) { + return projectNodes.filter(n => containsFile(n.files, file))[0]; +} + +export function findSourceProject( + projectNodes: ProjectNode[], + sourceFilePath: string +) { + const targetFile = removeExt(sourceFilePath); + return findProjectUsingFile(projectNodes, targetFile); +} + +export function findTargetProject( + projectNodes: ProjectNode[], + targetFile: string +) { + let targetProject = findProjectUsingFile(projectNodes, targetFile); + if (!targetProject) { + targetProject = findProjectUsingFile( + projectNodes, + normalizePath(path.join(targetFile, 'index')) + ); + } + if (!targetProject) { + targetProject = findProjectUsingFile( + projectNodes, + normalizePath(path.join(targetFile, 'src', 'index')) + ); + } + return targetProject; +} + +export function isAbsoluteImportIntoAnotherProject(imp: string) { + return ( + imp.startsWith('libs/') || + imp.startsWith('/libs/') || + imp.startsWith('apps/') || + imp.startsWith('/apps/') + ); +} + +export function findProjectUsingImport( + projectNodes: ProjectNode[], + npmScope: string, + imp: string +) { + const unscopedImport = imp.substring(npmScope.length + 2); + return projectNodes.filter(n => { + const normalizedRoot = normalizedProjectRoot(n); + return ( + unscopedImport === normalizedRoot || + unscopedImport.startsWith(`${normalizedRoot}/`) + ); + })[0]; +} + +export function isCircular( + deps: Deps, + sourceProject: ProjectNode, + targetProject: ProjectNode +): boolean { + if (!deps[targetProject.name]) return false; + return isDependingOn(deps, targetProject.name, sourceProject.name); +} + +function isDependingOn( + deps: Deps, + sourceProjectName: string, + targetProjectName: string, + done: { [projectName: string]: boolean } = {} +): boolean { + if (done[sourceProjectName]) return false; + if (!deps[sourceProjectName]) return false; + return deps[sourceProjectName] + .map(dep => + dep.projectName === targetProjectName + ? true + : isDependingOn(deps, dep.projectName, targetProjectName, { + ...done, + [`${sourceProjectName}`]: true + }) + ) + .some(result => result); +} + +export function findConstraintsFor( + depConstraints: DepConstraint[], + sourceProject: ProjectNode +) { + return depConstraints.filter(f => hasTag(sourceProject, f.sourceTag)); +} + +export function onlyLoadChildren( + deps: Deps, + sourceProjectName: string, + targetProjectName: string, + visited: string[] +) { + if (visited.indexOf(sourceProjectName) > -1) return false; + return ( + (deps[sourceProjectName] || []).filter(d => { + if (d.type !== DependencyType.loadChildren) return false; + if (d.projectName === targetProjectName) return true; + return onlyLoadChildren(deps, d.projectName, targetProjectName, [ + ...visited, + sourceProjectName + ]); + }).length > 0 + ); +} + +export function getSourceFilePath(sourceFileName: string, projectPath: string) { + return sourceFileName.substring(projectPath.length + 1); +} diff --git a/scripts/build.sh b/scripts/build.sh index 964542d8e0..2cecdc22de 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -50,6 +50,7 @@ cp README.md build/packages/jest cp README.md build/packages/cypress cp README.md build/packages/cli cp README.md build/packages/tao +cp README.md build/packages/eslint-plugin-nx cp README.md build/packages/linter cp LICENSE build/packages/builders @@ -67,6 +68,7 @@ cp LICENSE build/packages/jest cp LICENSE build/packages/cypress cp LICENSE build/packages/cli cp LICENSE build/packages/tao +cp LICENSE build/packages/eslint-plugin-nx cp LICENSE build/packages/linter echo "Nx libraries available at build/packages:" diff --git a/scripts/nx-release.js b/scripts/nx-release.js index 8c1fbb1746..f25982b3e2 100755 --- a/scripts/nx-release.js +++ b/scripts/nx-release.js @@ -163,6 +163,7 @@ const options = { 'build/npm/workspace/package.json', 'build/npm/cli/package.json', 'build/npm/tao/package.json', + 'build/npm/eslint-plugin-nx/package.json', 'build/npm/linter/package.json' ], increment: parsedVersion.version, diff --git a/scripts/package.sh b/scripts/package.sh index d9d106d398..7c2c045064 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -17,13 +17,13 @@ cd build/packages if [[ "$OSTYPE" == "darwin"* ]]; then sed -i "" "s|exports.nxVersion = '\*';|exports.nxVersion = '$NX_VERSION';|g" {react,web,jest,node,express,nest,cypress,angular,workspace}/src/utils/versions.js - sed -i "" "s|\*|$NX_VERSION|g" {schematics,react,web,jest,node,express,nest,cypress,angular,workspace,cli,tao,create-nx-workspace}/package.json + sed -i "" "s|\*|$NX_VERSION|g" {schematics,react,web,jest,node,express,nest,cypress,angular,workspace,cli,linter,tao,eslint-plugin-nx,create-nx-workspace}/package.json sed -i "" "s|NX_VERSION|$NX_VERSION|g" create-nx-workspace/bin/create-nx-workspace.js sed -i "" "s|ANGULAR_CLI_VERSION|$ANGULAR_CLI_VERSION|g" create-nx-workspace/bin/create-nx-workspace.js sed -i "" "s|TYPESCRIPT_VERSION|$TYPESCRIPT_VERSION|g" create-nx-workspace/bin/create-nx-workspace.js else sed -i "s|exports.nxVersion = '\*';|exports.nxVersion = '$NX_VERSION';|g" {react,web,jest,node,express,nest,cypress,angular,workspace}/src/utils/versions.js - sed -i "s|\*|$NX_VERSION|g" {schematics,react,web,jest,node,express,nest,cypress,angular,workspace,cli,tao,create-nx-workspace}/package.json + sed -i "s|\*|$NX_VERSION|g" {schematics,react,web,jest,node,express,nest,cypress,angular,workspace,cli,linter,tao,eslint-plugin-nx,create-nx-workspace}/package.json sed -i "s|NX_VERSION|$NX_VERSION|g" create-nx-workspace/bin/create-nx-workspace.js sed -i "s|ANGULAR_CLI_VERSION|$ANGULAR_CLI_VERSION|g" create-nx-workspace/bin/create-nx-workspace.js sed -i "s|TYPESCRIPT_VERSION|$TYPESCRIPT_VERSION|g" create-nx-workspace/bin/create-nx-workspace.js @@ -31,9 +31,9 @@ fi if [[ $NX_VERSION == "*" ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then - sed -E -i "" "s|\"@nrwl\/([^\"]+)\": \"\\*\"|\"@nrwl\/\1\": \"file:$PWD\/\1\"|" {schematics,jest,web,react,node,express,nest,cypress,angular,workspace,cli,tao,create-nx-workspace}/package.json + sed -E -i "" "s|\"@nrwl\/([^\"]+)\": \"\\*\"|\"@nrwl\/\1\": \"file:$PWD\/\1\"|" {schematics,jest,web,react,node,express,nest,cypress,angular,workspace,linter,cli,tao,eslint-plugin-nx,create-nx-workspace}/package.json else echo $PWD - sed -E -i "s|\"@nrwl\/([^\"]+)\": \"\\*\"|\"@nrwl\/\1\": \"file:$PWD\/\1\"|" {schematics,jest,web,react,node,express,nest,cypress,angular,workspace,cli,tao,create-nx-workspace}/package.json + sed -E -i "s|\"@nrwl\/([^\"]+)\": \"\\*\"|\"@nrwl\/\1\": \"file:$PWD\/\1\"|" {schematics,jest,web,react,node,express,nest,cypress,angular,workspace,linter,cli,tao,eslint-plugin-nx,create-nx-workspace}/package.json fi fi diff --git a/scripts/test.sh b/scripts/test.sh index 0911767476..b86a08ae3d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -3,5 +3,5 @@ if [ -n "$1" ]; then jest --maxWorkers=1 ./build/packages/$1.spec.js else - jest --maxWorkers=1 ./build/packages/{schematics,bazel,builders,react,jest,web,node,express,nest,cypress,angular,workspace,tao} --passWithNoTests + jest --maxWorkers=1 ./build/packages/{schematics,bazel,builders,react,jest,web,node,express,nest,cypress,angular,workspace,tao,eslint-plugin-nx} --passWithNoTests fi