TypeScript module resolution and lint updates (#2309)

* Revert "Revert "fix(core): sort node names for module resolution""

This reverts commit 0b77072fcfe63f8d02fccb73ba138aca99cb7f66.

* fix(core): add target project locator

This will sort nodes by length of the root (high to low) then nodes that have no root.

It also uses TypeScript to first try and resolve a module. If it is not found via TypeScript, it will
fall back to using a string match.
This commit is contained in:
Jonathan Cammisuli 2020-01-31 13:08:12 -05:00 committed by GitHub
parent c4ea49c3c6
commit 1c4078986f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 894 additions and 547 deletions

View File

@ -44,7 +44,6 @@ forEachCli(() => {
`
import '../../../libs/${mylib}';
import '@proj/${lazylib}';
import '@proj/${mylib}/deep';
import '@proj/${myapp2}';
import '@proj/${invalidtaglib}';
import '@proj/${validtaglib}';
@ -56,7 +55,6 @@ forEachCli(() => {
const out = runCLI(`lint ${myapp}`, { silenceError: true });
expect(out).toContain('library imports must start with @proj/');
expect(out).toContain('imports of lazy-loaded libraries are forbidden');
expect(out).toContain('deep imports into 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"'

View File

@ -26,6 +26,7 @@ import {
readNxJson,
readWorkspaceJson
} from '@nrwl/workspace/src/core/file-utils';
import { TargetProjectLocator } from '@nrwl/workspace/src/core/target-project-locator';
type Options = [
{
@ -107,6 +108,15 @@ export default createESLintRule<Options, MessageIds>({
}
const npmScope = (global as any).npmScope;
const projectGraph = (global as any).projectGraph as ProjectGraph;
if (!(global as any).targetProjectLocator) {
(global as any).targetProjectLocator = new TargetProjectLocator(
projectGraph.nodes
);
}
const targetProjectLocator = (global as any)
.targetProjectLocator as TargetProjectLocator;
return {
ImportDeclaration(node: TSESTree.ImportDeclaration) {
const imp = (node.source as TSESTree.Literal).value as string;
@ -148,8 +158,10 @@ export default createESLintRule<Options, MessageIds>({
// findProjectUsingImport to take care of same prefix
const targetProject = findProjectUsingImport(
projectGraph,
npmScope,
imp
targetProjectLocator,
sourceFilePath,
imp,
npmScope
);
// something went wrong => return.

View File

@ -1,19 +1,73 @@
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';
import {
DependencyType,
ProjectGraph,
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 { extname } from 'path';
import enforceModuleBoundaries, {
RULE_NAME as enforceModuleBoundariesRuleName
} from '../../src/rules/enforce-module-boundaries';
import { TargetProjectLocator } from '@nrwl/workspace/src/core/target-project-locator';
jest.mock('fs', () => require('memfs').fs);
jest.mock('@nrwl/workspace/src/utils/app-root', () => ({
appRootPath: '/root'
}));
const tsconfig = {
compilerOptions: {
baseUrl: '.',
paths: {
'@mycompany/impl': ['libs/impl/src/index.ts'],
'@mycompany/untagged': ['libs/untagged/src/index.ts'],
'@mycompany/api': ['libs/api/src/index.ts'],
'@mycompany/impl-domain2': ['libs/impl-domain2/src/index.ts'],
'@mycompany/impl-both-domains': ['libs/impl-both-domains/src/index.ts'],
'@mycompany/impl2': ['libs/impl2/src/index.ts'],
'@mycompany/other': ['libs/other/src/index.ts'],
'@mycompany/other/a/b': ['libs/other/src/a/b.ts'],
'@mycompany/other/a': ['libs/other/src/a/index.ts'],
'@mycompany/another/a/b': ['libs/another/a/b.ts'],
'@mycompany/myapp': ['apps/myapp/src/index.ts'],
'@mycompany/mylib': ['libs/mylib/src/index.ts'],
'@mycompany/mylibName': ['libs/mylibName/src/index.ts'],
'@mycompany/anotherlibName': ['libs/anotherlibName/src/index.ts'],
'@mycompany/badcirclelib': ['libs/badcirclelib/src/index.ts'],
'@mycompany/domain1': ['libs/domain1/src/index.ts'],
'@mycompany/domain2': ['libs/domain2/src/index.ts']
},
types: ['node']
},
exclude: ['**/*.spec.ts'],
include: ['**/*.ts']
};
const fileSys = {
'./libs/impl/src/index.ts': '',
'./libs/untagged/src/index.ts': '',
'./libs/api/src/index.ts': '',
'./libs/impl-domain2/src/index.ts': '',
'./libs/impl-both-domains/src/index.ts': '',
'./libs/impl2/src/index.ts': '',
'./libs/other/src/index.ts': '',
'./libs/other/src/a/b.ts': '',
'./libs/other/src/a/index.ts': '',
'./libs/another/a/b.ts': '',
'./apps/myapp/src/index.ts': '',
'./libs/mylib/src/index.ts': '',
'./libs/mylibName/src/index.ts': '',
'./libs/anotherlibName/src/index.ts': '',
'./libs/badcirclelib/src/index.ts': '',
'./libs/domain1/src/index.ts': '',
'./libs/domain2/src/index.ts': '',
'./tsconfig.json': JSON.stringify(tsconfig)
};
describe('Enforce Module Boundaries', () => {
beforeEach(() => {
spyOn(fs, 'writeFileSync');
vol.fromJSON(fileSys, '/root');
});
it('should not error when everything is in order', () => {
@ -131,26 +185,15 @@ describe('Enforce Module Boundaries', () => {
files: [createFile(`libs/api/src/index.ts`)]
}
},
implName: {
name: 'implName',
'impl-both-domainsName': {
name: 'impl-both-domainsName',
type: ProjectType.lib,
data: {
root: 'libs/impl',
tags: ['impl', 'domain1'],
root: 'libs/impl-both-domains',
tags: ['impl', 'domain1', 'domain2'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl/src/index.ts`)]
}
},
impl2Name: {
name: 'impl2Name',
type: ProjectType.lib,
data: {
root: 'libs/impl2',
tags: ['impl', 'domain1'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl2/src/index.ts`)]
files: [createFile(`libs/impl-both-domains/src/index.ts`)]
}
},
'impl-domain2Name': {
@ -164,15 +207,26 @@ describe('Enforce Module Boundaries', () => {
files: [createFile(`libs/impl-domain2/src/index.ts`)]
}
},
'impl-both-domainsName': {
name: 'impl-both-domainsName',
impl2Name: {
name: 'impl2Name',
type: ProjectType.lib,
data: {
root: 'libs/impl-both-domains',
tags: ['impl', 'domain1', 'domain2'],
root: 'libs/impl2',
tags: ['impl', 'domain1'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl-both-domains/src/index.ts`)]
files: [createFile(`libs/impl2/src/index.ts`)]
}
},
implName: {
name: 'implName',
type: ProjectType.lib,
data: {
root: 'libs/impl',
tags: ['impl', 'domain1'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl/src/index.ts`)]
}
},
untaggedName: {
@ -199,6 +253,10 @@ describe('Enforce Module Boundaries', () => {
]
};
beforeEach(() => {
vol.fromJSON(fileSys, '/root');
});
it('should error when the target library does not have the right tag', () => {
const failures = runRule(
depConstraints,
@ -480,211 +538,6 @@ describe('Enforce Module Boundaries', () => {
);
});
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"
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/mylib/src/main.ts`),
createFile(`libs/mylib/src/another-file.ts`)
]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/src/blah.ts`)]
}
},
otherSublibName: {
name: 'otherSublibName',
type: ProjectType.lib,
data: {
root: 'libs/other/sublib',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/sublib/src/blah.ts`)]
}
}
},
dependencies: {}
}
);
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"
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/mylib/src/main.ts`),
createFile(`libs/mylib/src/another-file.ts`)
]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/src/blah.ts`)]
}
}
},
dependencies: {}
}
);
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/**', '@mycompany/**/testing'] },
`${process.cwd()}/proj/libs/mylib/src/main.ts`,
`
import "@mycompany/other/src/blah"
import "@mycompany/another/testing"
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/mylib/src/main.ts`)]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/src/blah.ts`)]
}
},
anotherName: {
name: 'anotherName',
type: ProjectType.lib,
data: {
root: 'libs/another',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/anotherlib/testing.ts`)]
}
}
},
dependencies: {}
}
);
expect(failures.length).toEqual(0);
});
it('should not error about one level 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/a/b";
import "@mycompany/other/a";
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/mylib/src/main.ts`)]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/other/a/index.ts`),
createFile(`libs/other/a/b.ts`)
]
}
},
anotherName: {
name: 'anotherName',
type: ProjectType.lib,
data: {
root: 'libs/another',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/another/a/index.ts`),
createFile(`libs/another/a/b.ts`)
]
}
}
},
dependencies: {}
}
);
expect(failures.length).toEqual(1);
});
it('should respect regexp in allow option', () => {
const failures = runRule(
{ allow: ['^.*/utils/.*$'] },
@ -1146,6 +999,9 @@ function runRule(
(global as any).projectPath = `${process.cwd()}/proj`;
(global as any).npmScope = 'mycompany';
(global as any).projectGraph = projectGraph;
(global as any).targetProjectLocator = new TargetProjectLocator(
projectGraph.nodes
);
const config = {
...baseConfig,

View File

@ -13,6 +13,7 @@ jest.mock('../../utils/app-root', () => ({ appRootPath: '/root' }));
describe('project graph', () => {
let packageJson: any;
let workspaceJson: any;
let tsConfigJson: any;
let nxJson: NxJson;
let filesJson: any;
let filesAtMasterJson: any;
@ -82,6 +83,15 @@ describe('project graph', () => {
util: { tags: [] }
}
};
tsConfigJson = {
compilerOptions: {
baseUrl: '.',
paths: {
'@nrwl/ui': ['libs/ui/src/index.ts'],
'@nrwl/util': ['libs/util/src/index.ts']
}
}
};
filesJson = {
'./apps/api/src/index.ts': stripIndents`
console.log('starting server');
@ -100,7 +110,8 @@ describe('project graph', () => {
`,
'./package.json': JSON.stringify(packageJson),
'./nx.json': JSON.stringify(nxJson),
'./workspace.json': JSON.stringify(workspaceJson)
'./workspace.json': JSON.stringify(workspaceJson),
'./tsconfig.json': JSON.stringify(tsConfigJson)
};
files = Object.keys(filesJson).map(f => ({
file: f,

View File

@ -0,0 +1,205 @@
jest.mock('../../../utils/app-root', () => ({
appRootPath: '/root'
}));
jest.mock('fs', () => require('memfs').fs);
import { fs, vol } from 'memfs';
import {
AddProjectDependency,
ProjectGraphContext,
ProjectGraphNode,
DependencyType
} from '../project-graph-models';
import { buildExplicitTypeScriptDependencies } from './explicit-project-dependencies';
import { createFileMap } from '../../file-graph';
import { readWorkspaceFiles } from '../../file-utils';
import { appRootPath } from '../../../utils/app-root';
import { string } from 'prop-types';
describe('explicit project dependencies', () => {
let ctx: ProjectGraphContext;
let projects: Record<string, ProjectGraphNode>;
let fsJson;
beforeEach(() => {
const workspaceJson = {
projects: {
proj: {
root: 'libs/proj'
},
proj2: {
root: 'libs/proj2'
},
proj3a: {
root: 'libs/proj3a'
},
proj123: {
root: 'libs/proj123'
},
proj1234: {
root: 'libs/proj1234'
},
'proj1234-child': {
root: 'libs/proj1234-child'
}
}
};
const nxJson = {
npmScope: 'proj',
projects: {
proj1: {}
}
};
const tsConfig = {
compilerOptions: {
baseUrl: '.',
paths: {
'@proj/proj': ['libs/proj/index.ts'],
'@proj/my-second-proj': ['libs/proj2/index.ts'],
'@proj/project-3': ['libs/proj3a/index.ts'],
'@proj/proj123': ['libs/proj123/index.ts'],
'@proj/proj1234': ['libs/proj1234/index.ts'],
'@proj/proj1234-child': ['libs/proj1234-child/index.ts']
}
}
};
fsJson = {
'./package.json': `{
"name": "test",
"dependencies": [],
"devDependencies": []
}`,
'./workspace.json': JSON.stringify(workspaceJson),
'./nx.json': JSON.stringify(nxJson),
'./tsconfig.json': JSON.stringify(tsConfig),
'./libs/proj/index.ts': `import {a} from '@proj/my-second-proj';
import('@proj/project-3');
const a = { loadChildren: '@proj/proj4ab#a' };
`,
'./libs/proj2/index.ts': `export const a = 2;`,
'./libs/proj3a/index.ts': `export const a = 3;`,
'./libs/proj4ab/index.ts': `export const a = 4;`,
'./libs/proj123/index.ts': 'export const a = 5',
'./libs/proj1234/index.ts': `export const a = 6
import { a } from '@proj/proj1234-child'
`,
'./libs/proj1234-child/index.ts': 'export const a = 7'
};
vol.fromJSON(fsJson, '/root');
ctx = {
workspaceJson,
nxJson,
fileMap: createFileMap(workspaceJson, readWorkspaceFiles())
};
projects = {
proj3a: {
name: 'proj3a',
type: 'lib',
data: {
root: 'libs/proj3a',
files: []
}
},
proj2: {
name: 'proj2',
type: 'lib',
data: {
root: 'libs/proj2',
files: []
}
},
proj: {
name: 'proj',
type: 'lib',
data: {
root: 'libs/proj',
files: []
}
},
proj1234: {
name: 'proj1234',
type: 'lib',
data: {
root: 'libs/proj1234',
files: []
}
},
proj123: {
name: 'proj123',
type: 'lib',
data: {
root: 'libs/proj123',
files: []
}
},
proj4ab: {
name: 'proj4ab',
type: 'lib',
data: {
root: 'libs/proj4ab',
files: []
}
},
'proj1234-child': {
name: 'proj1234-child',
type: 'lib',
data: {
root: 'libs/proj1234-child',
files: []
}
}
};
});
it(`should add dependencies for projects based on file imports`, () => {
const dependencyMap = {};
const addDependency = jest
.fn<ReturnType<AddProjectDependency>, Parameters<AddProjectDependency>>()
.mockImplementation(
(type: DependencyType, source: string, target: string) => {
const depObj = {
type,
source,
target
};
if (dependencyMap[source]) {
dependencyMap[source].push(depObj);
} else {
dependencyMap[source] = [depObj];
}
}
);
buildExplicitTypeScriptDependencies(ctx, projects, addDependency, s => {
return fs.readFileSync(`${appRootPath}/${s}`).toString();
});
expect(dependencyMap).toEqual({
proj1234: [
{
source: 'proj1234',
target: 'proj1234-child',
type: DependencyType.static
}
],
proj: [
{
source: 'proj',
target: 'proj2',
type: DependencyType.static
},
{
source: 'proj',
target: 'proj3a',
type: DependencyType.dynamic
},
{
source: 'proj',
target: 'proj4ab',
type: DependencyType.dynamic
}
]
});
});
});

View File

@ -5,7 +5,7 @@ import {
ProjectGraphNodeRecords
} from '../project-graph-models';
import { TypeScriptImportLocator } from './typescript-import-locator';
import { normalizedProjectRoot } from '../../file-utils';
import { TargetProjectLocator } from '../../target-project-locator';
export function buildExplicitTypeScriptDependencies(
ctx: ProjectGraphContext,
@ -14,13 +14,17 @@ export function buildExplicitTypeScriptDependencies(
fileRead: (s: string) => string
) {
const importLocator = new TypeScriptImportLocator(fileRead);
const targetProjectLocator = new TargetProjectLocator(nodes);
Object.keys(ctx.fileMap).forEach(source => {
Object.values(ctx.fileMap[source]).forEach(f => {
importLocator.fromFile(
f.file,
(importExpr: string, filePath: string, type: DependencyType) => {
const target = findTargetProject(importExpr, nodes);
const target = targetProjectLocator.findProjectWithImport(
importExpr,
f.file,
ctx.nxJson.npmScope
);
if (source && target) {
addDependency(type, source, target);
}
@ -28,16 +32,4 @@ export function buildExplicitTypeScriptDependencies(
);
});
});
function findTargetProject(importExpr, nodes) {
return Object.keys(nodes).find(projectName => {
const p = nodes[projectName];
const normalizedRoot = normalizedProjectRoot(p);
return (
importExpr === `@${ctx.nxJson.npmScope}/${normalizedRoot}` ||
importExpr.startsWith(`@${ctx.nxJson.npmScope}/${normalizedRoot}#`) ||
importExpr.startsWith(`@${ctx.nxJson.npmScope}/${normalizedRoot}/`)
);
});
}
}

View File

@ -13,6 +13,7 @@ describe('project graph', () => {
let packageJson: any;
let workspaceJson: any;
let nxJson: NxJson;
let tsConfigJson: any;
let filesJson: any;
let files: FileData[];
@ -48,6 +49,16 @@ describe('project graph', () => {
sourceRoot: 'libs/shared/util/src',
projectType: 'library'
},
'shared-util-data': {
root: 'libs/shared/util/data',
sourceRoot: 'libs/shared/util/data/src',
projectType: 'library'
},
'lazy-lib': {
root: 'libs/lazy-lib',
sourceRoot: 'libs/lazy-lib',
projectType: 'library'
},
api: {
root: 'apps/api/',
sourceRoot: 'apps/api/src',
@ -69,7 +80,20 @@ describe('project graph', () => {
demo: { tags: [], implicitDependencies: ['api'] },
'demo-e2e': { tags: [] },
ui: { tags: [] },
'shared-util': { tags: [] }
'shared-util': { tags: [] },
'shared-util-data': { tags: [] },
'lazy-lib': { tags: [] }
}
};
tsConfigJson = {
compilerOptions: {
baseUrl: '.',
paths: {
'@nrwl/shared/util': ['libs/shared/util/src/index.ts'],
'@nrwl/shared-util-data': ['libs/shared/util/data/src/index.ts'],
'@nrwl/ui': ['libs/ui/src/index.ts'],
'@nrwl/lazy-lib': ['libs/lazy-lib/src/index.ts']
}
}
};
filesJson = {
@ -78,6 +102,9 @@ describe('project graph', () => {
`,
'./apps/demo/src/index.ts': stripIndents`
import * as ui from '@nrwl/ui';
import * as data from '@nrwl/shared-util-data;
const s = { loadChildren: '@nrwl/lazy-lib#LAZY' }
`,
'./apps/demo-e2e/src/integration/app.spec.ts': stripIndents`
describe('whatever', () => {});
@ -88,9 +115,16 @@ describe('project graph', () => {
'./libs/shared/util/src/index.ts': stripIndents`
import * as happyNrwl from 'happy-nrwl/a/b/c';
`,
'./libs/shared/util/data/src/index.ts': stripIndents`
export const SHARED_DATA = 'shared data';
`,
'./libs/lazy-lib/src/index.ts': stripIndents`
export const LAZY = 'lazy lib';
`,
'./package.json': JSON.stringify(packageJson),
'./nx.json': JSON.stringify(nxJson),
'./workspace.json': JSON.stringify(workspaceJson)
'./workspace.json': JSON.stringify(workspaceJson),
'./tsconfig.json': JSON.stringify(tsConfigJson)
};
files = Object.keys(filesJson).map(f => ({
file: f,
@ -108,18 +142,39 @@ describe('project graph', () => {
'demo-e2e': { name: 'demo-e2e', type: 'e2e' },
demo: { name: 'demo', type: 'app' },
ui: { name: 'ui', type: 'lib' },
'shared-util': { name: 'shared-util', type: 'lib' }
'shared-util': { name: 'shared-util', type: 'lib' },
'shared-util-data': { name: 'shared-util-data', type: 'lib' },
'lazy-lib': { name: 'lazy-lib', type: 'lib' },
'happy-nrwl': { name: 'happy-nrwl', type: 'npm' }
});
expect(graph.dependencies).toMatchObject({
'demo-e2e': [
{ type: DependencyType.implicit, source: 'demo-e2e', target: 'demo' }
],
demo: expect.arrayContaining([
{ type: DependencyType.implicit, source: 'demo', target: 'api' },
{ type: DependencyType.static, source: 'demo', target: 'ui' }
]),
ui: [{ type: DependencyType.static, source: 'ui', target: 'shared-util' }]
demo: [
{ type: DependencyType.static, source: 'demo', target: 'ui' },
{
type: DependencyType.static,
source: 'demo',
target: 'shared-util-data'
},
{
type: DependencyType.dynamic,
source: 'demo',
target: 'lazy-lib'
},
{ type: DependencyType.implicit, source: 'demo', target: 'api' }
],
ui: [
{ type: DependencyType.static, source: 'ui', target: 'shared-util' }
],
'shared-util': [
{
type: DependencyType.static,
source: 'shared-util',
target: 'happy-nrwl'
}
]
});
});

View File

@ -0,0 +1,261 @@
import { fs, vol } from 'memfs';
import { join } from 'path';
import {
ProjectGraphContext,
ProjectGraphNode
} from './project-graph/project-graph-models';
import { TargetProjectLocator } from './target-project-locator';
jest.mock('../utils/app-root', () => ({
appRootPath: '/root'
}));
jest.mock('fs', () => require('memfs').fs);
describe('findTargetProjectWithImport', () => {
let ctx: ProjectGraphContext;
let projects: Record<string, ProjectGraphNode>;
let fsJson;
let targetProjectLocator: TargetProjectLocator;
beforeEach(() => {
const workspaceJson = {
projects: {
proj1: {}
}
};
const nxJson = {
npmScope: 'proj',
projects: {
proj1: {}
}
};
const tsConfig = {
compilerOptions: {
baseUrl: '.',
paths: {
'@proj/proj': ['libs/proj/index.ts'],
'@proj/my-second-proj': ['libs/proj2/index.ts'],
'@proj/project-3': ['libs/proj3a/index.ts'],
'@proj/proj123': ['libs/proj123/index.ts'],
'@proj/proj1234': ['libs/proj1234/index.ts'],
'@proj/proj1234-child': ['libs/proj1234-child/index.ts']
}
}
};
fsJson = {
'./workspace.json': JSON.stringify(workspaceJson),
'./nx.json': JSON.stringify(nxJson),
'./tsconfig.json': JSON.stringify(tsConfig),
'./libs/proj/index.ts': `import {a} from '@proj/my-second-proj';
import('@proj/project-3');
const a = { loadChildren: '@proj/proj4ab#a' };
`,
'./libs/proj2/index.ts': `export const a = 2;`,
'./libs/proj3a/index.ts': `export const a = 3;`,
'./libs/proj4ab/index.ts': `export const a = 4;`,
'./libs/proj123/index.ts': 'export const a = 5',
'./libs/proj1234/index.ts': 'export const a = 6',
'./libs/proj1234-child/index.ts': 'export const a = 7'
};
vol.fromJSON(fsJson, '/root');
ctx = {
workspaceJson,
nxJson,
fileMap: {
proj: [
{
file: 'libs/proj/index.ts',
mtime: 0,
ext: '.ts'
}
],
proj2: [
{
file: 'libs/proj2/index.ts',
mtime: 0,
ext: '.ts'
}
],
proj3a: [
{
file: 'libs/proj3a/index.ts',
mtime: 0,
ext: '.ts'
}
],
proj4ab: [
{
file: 'libs/proj4ab/index.ts',
mtime: 0,
ext: '.ts'
}
],
proj123: [
{
file: 'libs/proj123/index.ts',
mtime: 0,
ext: '.ts'
}
],
proj1234: [
{
file: 'libs/proj1234/index.ts',
mtime: 0,
ext: '.ts'
}
],
'proj1234-child': [
{
file: 'libs/proj1234-child/index.ts',
mtime: 0,
ext: '.ts'
}
]
}
};
projects = {
proj3a: {
name: 'proj3a',
type: 'lib',
data: {
root: 'libs/proj3a',
files: []
}
},
proj2: {
name: 'proj2',
type: 'lib',
data: {
root: 'libs/proj2',
files: []
}
},
proj: {
name: 'proj',
type: 'lib',
data: {
root: 'libs/proj',
files: []
}
},
proj1234: {
name: 'proj1234',
type: 'lib',
data: {
root: 'libs/proj1234',
files: []
}
},
proj123: {
name: 'proj123',
type: 'lib',
data: {
root: 'libs/proj123',
files: []
}
},
proj4ab: {
name: 'proj4ab',
type: 'lib',
data: {
root: 'libs/proj4ab',
files: []
}
},
'@ng/core': {
name: '@ng/core',
type: 'npm',
data: {
files: []
}
},
'@ng/common': {
name: '@ng/common',
type: 'npm',
data: {
files: []
}
},
'npm-package': {
name: 'npm-package',
type: 'npm',
data: {
files: []
}
},
'proj1234-child': {
name: 'proj1234-child',
type: 'lib',
data: {
root: 'libs/proj1234-child',
files: []
}
}
};
targetProjectLocator = new TargetProjectLocator(projects);
});
it('should be able to resolve a module by using tsConfig paths', () => {
const proj2 = targetProjectLocator.findProjectWithImport(
'@proj/my-second-proj',
'libs/proj1/index.ts',
ctx.nxJson.npmScope
);
const proj3a = targetProjectLocator.findProjectWithImport(
'@proj/project-3',
'libs/proj1/index.ts',
ctx.nxJson.npmScope
);
expect(proj2).toEqual('proj2');
expect(proj3a).toEqual('proj3a');
});
it('should be able to resolve a module using a normalized path', () => {
const proj4ab = targetProjectLocator.findProjectWithImport(
'@proj/proj4ab',
'libs/proj1/index.ts',
ctx.nxJson.npmScope
);
expect(proj4ab).toEqual('proj4ab');
});
it('should be able to resolve paths that have similar names', () => {
const proj = targetProjectLocator.findProjectWithImport(
'@proj/proj123',
'',
ctx.nxJson.npmScope
);
expect(proj).toEqual('proj123');
const childProj = targetProjectLocator.findProjectWithImport(
'@proj/proj1234-child',
'',
ctx.nxJson.npmScope
);
expect(childProj).toEqual('proj1234-child');
const parentProj = targetProjectLocator.findProjectWithImport(
'@proj/proj1234',
'',
ctx.nxJson.npmScope
);
expect(parentProj).toEqual('proj1234');
});
it('should be able to sort graph nodes', () => {
expect(targetProjectLocator._sortedNodeNames).toEqual([
'proj1234-child',
'proj1234',
'proj123',
'proj4ab',
'proj3a',
'proj2',
'proj',
'@ng/core',
'@ng/common',
'npm-package'
]);
});
});

View File

@ -0,0 +1,74 @@
import { resolveModuleByImport } from '../utils/typescript';
import { normalizedProjectRoot } from './file-utils';
import {
ProjectGraphNodeRecords,
ProjectType
} from './project-graph/project-graph-models';
export class TargetProjectLocator {
_sortedNodeNames = [];
constructor(private nodes: ProjectGraphNodeRecords) {
this._sortedNodeNames = Object.keys(nodes).sort((a, b) => {
// If a or b is not a nx project, leave them in the same spot
if (
!this._isNxProject(nodes[a].type) &&
!this._isNxProject(nodes[b].type)
) {
return 0;
}
// sort all non-projects lower
if (
!this._isNxProject(nodes[a].type) &&
this._isNxProject(nodes[b].type)
) {
return 1;
}
if (
this._isNxProject(nodes[a].type) &&
!this._isNxProject(nodes[b].type)
) {
return -1;
}
return nodes[a].data.root.length > nodes[b].data.root.length ? -1 : 1;
});
}
findProjectWithImport(
importExpr: string,
filePath: string,
npmScope: string
) {
const normalizedImportExpr = importExpr.split('#')[0];
const resolvedModule = resolveModuleByImport(
normalizedImportExpr,
filePath
);
return this._sortedNodeNames.find(projectName => {
const p = this.nodes[projectName];
if (!this._isNxProject(p.type)) {
return false;
}
if (resolvedModule && resolvedModule.startsWith(p.data.root)) {
return true;
} else {
const normalizedRoot = normalizedProjectRoot(p);
const projectImport = `@${npmScope}/${normalizedRoot}`;
return normalizedImportExpr.startsWith(projectImport);
}
});
}
private _isNxProject(type: string) {
return (
type === ProjectType.app ||
type === ProjectType.lib ||
type === ProjectType.e2e
);
}
}

View File

@ -1,15 +1,70 @@
import { vol } from 'memfs';
import { extname } from 'path';
import { RuleFailure } from 'tslint';
import * as ts from 'typescript';
import * as fs from 'fs';
import {
DependencyType,
ProjectGraph,
ProjectType
} from '../core/project-graph';
import { Rule } from './nxEnforceModuleBoundariesRule';
import { DependencyType, ProjectGraph } from '../core/project-graph';
import { ProjectType } from '../core/project-graph';
import { extname } from 'path';
import { TargetProjectLocator } from '../core/target-project-locator';
jest.mock('fs', () => require('memfs').fs);
jest.mock('../utils/app-root', () => ({ appRootPath: '/root' }));
const tsconfig = {
compilerOptions: {
baseUrl: '.',
paths: {
'@mycompany/impl': ['libs/impl/src/index.ts'],
'@mycompany/untagged': ['libs/untagged/src/index.ts'],
'@mycompany/api': ['libs/api/src/index.ts'],
'@mycompany/impl-domain2': ['libs/impl-domain2/src/index.ts'],
'@mycompany/impl-both-domains': ['libs/impl-both-domains/src/index.ts'],
'@mycompany/impl2': ['libs/impl2/src/index.ts'],
'@mycompany/other': ['libs/other/src/index.ts'],
'@mycompany/other/a/b': ['libs/other/src/a/b.ts'],
'@mycompany/other/a': ['libs/other/src/a/index.ts'],
'@mycompany/another/a/b': ['libs/another/a/b.ts'],
'@mycompany/myapp': ['apps/myapp/src/index.ts'],
'@mycompany/mylib': ['libs/mylib/src/index.ts'],
'@mycompany/mylibName': ['libs/mylibName/src/index.ts'],
'@mycompany/anotherlibName': ['libs/anotherlibName/src/index.ts'],
'@mycompany/badcirclelib': ['libs/badcirclelib/src/index.ts'],
'@mycompany/domain1': ['libs/domain1/src/index.ts'],
'@mycompany/domain2': ['libs/domain2/src/index.ts']
},
types: ['node']
},
exclude: ['**/*.spec.ts'],
include: ['**/*.ts']
};
const fileSys = {
'./libs/impl/src/index.ts': '',
'./libs/untagged/src/index.ts': '',
'./libs/api/src/index.ts': '',
'./libs/impl-domain2/src/index.ts': '',
'./libs/impl-both-domains/src/index.ts': '',
'./libs/impl2/src/index.ts': '',
'./libs/other/src/index.ts': '',
'./libs/other/src/a/b.ts': '',
'./libs/other/src/a/index.ts': '',
'./libs/another/a/b.ts': '',
'./apps/myapp/src/index.ts': '',
'./libs/mylib/src/index.ts': '',
'./libs/mylibName/src/index.ts': '',
'./libs/anotherlibName/src/index.ts': '',
'./libs/badcirclelib/src/index.ts': '',
'./libs/domain1/src/index.ts': '',
'./libs/domain2/src/index.ts': '',
'./tsconfig.json': JSON.stringify(tsconfig)
};
describe('Enforce Module Boundaries', () => {
beforeEach(() => {
spyOn(fs, 'writeFileSync');
vol.fromJSON(fileSys, '/root');
});
it('should not error when everything is in order', () => {
@ -126,26 +181,15 @@ describe('Enforce Module Boundaries', () => {
files: [createFile(`libs/api/src/index.ts`)]
}
},
implName: {
name: 'implName',
'impl-both-domainsName': {
name: 'impl-both-domainsName',
type: ProjectType.lib,
data: {
root: 'libs/impl',
tags: ['impl', 'domain1'],
root: 'libs/impl-both-domains',
tags: ['impl', 'domain1', 'domain2'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl/src/index.ts`)]
}
},
impl2Name: {
name: 'impl2Name',
type: ProjectType.lib,
data: {
root: 'libs/impl2',
tags: ['impl', 'domain1'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl2/src/index.ts`)]
files: [createFile(`libs/impl-both-domains/src/index.ts`)]
}
},
'impl-domain2Name': {
@ -159,15 +203,26 @@ describe('Enforce Module Boundaries', () => {
files: [createFile(`libs/impl-domain2/src/index.ts`)]
}
},
'impl-both-domainsName': {
name: 'impl-both-domainsName',
impl2Name: {
name: 'impl2Name',
type: ProjectType.lib,
data: {
root: 'libs/impl-both-domains',
tags: ['impl', 'domain1', 'domain2'],
root: 'libs/impl2',
tags: ['impl', 'domain1'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl-both-domains/src/index.ts`)]
files: [createFile(`libs/impl2/src/index.ts`)]
}
},
implName: {
name: 'implName',
type: ProjectType.lib,
data: {
root: 'libs/impl',
tags: ['impl', 'domain1'],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/impl/src/index.ts`)]
}
},
untaggedName: {
@ -194,6 +249,10 @@ describe('Enforce Module Boundaries', () => {
]
};
beforeEach(() => {
vol.fromJSON(fileSys, '/root');
});
it('should error when the target library does not have the right tag', () => {
const failures = runRule(
depConstraints,
@ -476,212 +535,6 @@ describe('Enforce Module Boundaries', () => {
);
});
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"
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/mylib/src/main.ts`),
createFile(`libs/mylib/src/another-file.ts`)
]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/src/blah.ts`)]
}
},
otherSublibName: {
name: 'otherSublibName',
type: ProjectType.lib,
data: {
root: 'libs/other/sublib',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/sublib/src/blah.ts`)]
}
}
},
dependencies: {}
}
);
expect(failures[0].getFailure()).toEqual(
'deep imports into libraries are forbidden'
);
expect(failures[1].getFailure()).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"
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/mylib/src/main.ts`),
createFile(`libs/mylib/src/another-file.ts`)
]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/src/blah.ts`)]
}
}
},
dependencies: {}
}
);
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/**', '@mycompany/**/testing'] },
`${process.cwd()}/proj/libs/mylib/src/main.ts`,
`
import "@mycompany/other/src/blah"
import "@mycompany/another/testing"
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/mylib/src/main.ts`)]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/other/src/blah.ts`)]
}
},
anotherName: {
name: 'anotherName',
type: ProjectType.lib,
data: {
root: 'libs/another',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/anotherlib/testing.ts`)]
}
}
},
dependencies: {}
}
);
expect(failures.length).toEqual(0);
});
it('should not error about one level deep imports into library when exception is specified with a wildcard', () => {
const failures = runRule(
{ allow: ['@mycompany/other/*', '@mycompany/another/*'] },
`${process.cwd()}/proj/libs/mylib/src/main.ts`,
`
import "@mycompany/other/a/b";
import "@mycompany/other/a";
import "@mycompany/another/a/b";
`,
{
nodes: {
mylibName: {
name: 'mylibName',
type: ProjectType.lib,
data: {
root: 'libs/mylib',
tags: [],
implicitDependencies: [],
architect: {},
files: [createFile(`libs/mylib/src/main.ts`)]
}
},
otherName: {
name: 'otherName',
type: ProjectType.lib,
data: {
root: 'libs/other',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/other/a/index.ts`),
createFile(`libs/other/a/b.ts`)
]
}
},
anotherName: {
name: 'anotherName',
type: ProjectType.lib,
data: {
root: 'libs/another',
tags: [],
implicitDependencies: [],
architect: {},
files: [
createFile(`libs/another/a/index.ts`),
createFile(`libs/another/a/b.ts`)
]
}
}
},
dependencies: {}
}
);
expect(failures.length).toEqual(2);
});
it('should respect regexp in allow option', () => {
const failures = runRule(
{ allow: ['^.*/utils/.*$'] },
@ -1143,7 +996,8 @@ function runRule(
options,
`${process.cwd()}/proj`,
'mycompany',
projectGraph
projectGraph,
new TargetProjectLocator(projectGraph.nodes)
);
return rule.apply(sourceFile);
}

View File

@ -29,13 +29,15 @@ import {
readNxJson,
readWorkspaceJson
} from '@nrwl/workspace/src/core/file-utils';
import { TargetProjectLocator } from '../core/target-project-locator';
export class Rule extends Lint.Rules.AbstractRule {
constructor(
options: IOptions,
private readonly projectPath?: string,
private readonly npmScope?: string,
private readonly projectGraph?: ProjectGraph
private readonly projectGraph?: ProjectGraph,
private readonly targetProjectLocator?: TargetProjectLocator
) {
super(options);
@ -52,6 +54,13 @@ export class Rule extends Lint.Rules.AbstractRule {
}
this.npmScope = (global as any).npmScope;
this.projectGraph = (global as any).projectGraph;
if (!(global as any).targetProjectLocator) {
(global as any).targetProjectLocator = new TargetProjectLocator(
this.projectGraph.nodes
);
}
this.targetProjectLocator = (global as any).targetProjectLocator;
}
}
@ -62,7 +71,8 @@ export class Rule extends Lint.Rules.AbstractRule {
this.getOptions(),
this.projectPath,
this.npmScope,
this.projectGraph
this.projectGraph,
this.targetProjectLocator
)
);
}
@ -78,7 +88,8 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker {
options: IOptions,
private readonly projectPath: string,
private readonly npmScope: string,
private readonly projectGraph: ProjectGraph
private readonly projectGraph: ProjectGraph,
private readonly targetProjectLocator: TargetProjectLocator
) {
super(sourceFile, options);
@ -129,15 +140,18 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker {
// check constraints between libs and apps
if (imp.startsWith(`@${this.npmScope}/`)) {
// we should find the name
const sourceProject = findSourceProject(
this.projectGraph,
getSourceFilePath(this.getSourceFile().fileName, this.projectPath)
const filePath = getSourceFilePath(
this.getSourceFile().fileName,
this.projectPath
);
const sourceProject = findSourceProject(this.projectGraph, filePath);
// findProjectUsingImport to take care of same prefix
const targetProject = findProjectUsingImport(
this.projectGraph,
this.npmScope,
imp
this.targetProjectLocator,
filePath,
imp,
this.npmScope
);
// something went wrong => return.
@ -169,16 +183,6 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker {
return;
}
// deep imports aren't allowed
if (imp !== `@${this.npmScope}/${normalizedProjectRoot(targetProject)}`) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
'deep imports into libraries are forbidden'
);
return;
}
// buildable-lib is not allowed to import non-buildable-lib
if (
this.enforceBuildableLibDependency === true &&
@ -198,16 +202,6 @@ class EnforceModuleBoundariesWalker extends Lint.RuleWalker {
}
}
// deep imports aren't allowed
if (imp !== `@${this.npmScope}/${normalizedProjectRoot(targetProject)}`) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
'deep imports into libraries are forbidden'
);
return;
}
// if we import a library using loadChildren, we should not import it using es6imports
if (
onlyLoadChildren(

View File

@ -1,12 +1,13 @@
import { normalize } from '@angular-devkit/core';
import * as path from 'path';
import { FileData } from '../core/file-utils';
import {
DependencyType,
ProjectGraphNode,
ProjectGraph,
ProjectGraphDependency,
ProjectGraph
ProjectGraphNode
} from '../core/project-graph';
import { normalize } from '@angular-devkit/core';
import { FileData, normalizedProjectRoot } from '../core/file-utils';
import { TargetProjectLocator } from '../core/target-project-locator';
export type Deps = { [projectName: string]: ProjectGraphDependency[] };
export type DepConstraint = {
@ -133,25 +134,17 @@ export function isAbsoluteImportIntoAnotherProject(imp: string) {
export function findProjectUsingImport(
projectGraph: ProjectGraph,
npmScope: string,
imp: string
targetProjectLocator: TargetProjectLocator,
filePath: string,
imp: string,
npmScope: string
) {
const unscopedImport = imp.substring(npmScope.length + 2);
let bestMatchedRoot: string = '';
let bestMatch: ProjectGraphNode = undefined;
Object.values(projectGraph.nodes).forEach(n => {
const normalizedRoot = normalizedProjectRoot(n);
if (
unscopedImport === normalizedRoot ||
unscopedImport.startsWith(`${normalizedRoot}/`)
) {
if (normalizedRoot.length > bestMatchedRoot.length) {
bestMatchedRoot = normalizedRoot;
bestMatch = n;
}
}
});
return bestMatch;
const target = targetProjectLocator.findProjectWithImport(
imp,
filePath,
npmScope
);
return projectGraph.nodes[target];
}
export function isCircular(

View File

@ -1,5 +1,6 @@
import * as ts from 'typescript';
import { dirname } from 'path';
import * as ts from 'typescript';
import { appRootPath } from './app-root';
export function readTsConfig(tsConfigPath: string) {
const readResult = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
@ -9,3 +10,44 @@ export function readTsConfig(tsConfigPath: string) {
dirname(tsConfigPath)
);
}
let compilerHost: {
host: ts.CompilerHost;
options: ts.CompilerOptions;
moduleResolutionCache: ts.ModuleResolutionCache;
};
/**
* Find a module based on it's import
*
* @param importExpr Import used to resolve to a module
* @param filePath
*/
export function resolveModuleByImport(importExpr: string, filePath: string) {
compilerHost = compilerHost || getCompilerHost();
const { options, host, moduleResolutionCache } = compilerHost;
const { resolvedModule } = ts.resolveModuleName(
importExpr,
filePath,
options,
host,
moduleResolutionCache
);
if (!resolvedModule) {
return;
} else {
return resolvedModule.resolvedFileName.replace(`${appRootPath}/`, '');
}
}
function getCompilerHost() {
const { options } = readTsConfig(`${appRootPath}/tsconfig.json`);
const host = ts.createCompilerHost(options, true);
const moduleResolutionCache = ts.createModuleResolutionCache(
appRootPath,
host.getCanonicalFileName
);
return { options, host, moduleResolutionCache };
}