nx/packages/schematics/src/tslint/nxEnforceModuleBoundariesRule.ts

322 lines
9.2 KiB
TypeScript

import * as path from 'path';
import * as Lint from 'tslint';
import { IOptions } from 'tslint';
import * as ts from 'typescript';
import { readFileSync } from 'fs';
import * as appRoot from 'app-root-path';
import { getProjectNodes, readDependencies } from '../command-line/shared';
import {
Dependency,
DependencyType,
ProjectNode,
ProjectType
} from '../command-line/affected-apps';
export class Rule extends Lint.Rules.AbstractRule {
constructor(
options: IOptions,
private readonly projectPath?: string,
private readonly npmScope?: string,
private readonly projectNodes?: ProjectNode[],
private readonly deps?: { [projectName: string]: Dependency[] }
) {
super(options);
if (!projectPath) {
this.projectPath = appRoot.path;
if (!(global as any).projectNodes) {
const cliConfig = this.readCliConfig(this.projectPath);
(global as any).npmScope = cliConfig.project.npmScope;
(global as any).projectNodes = getProjectNodes(cliConfig);
(global as any).deps = readDependencies(
(global as any).npmScope,
(global as any).projectNodes
);
}
this.npmScope = (global as any).npmScope;
this.projectNodes = (global as any).projectNodes;
this.deps = (global as any).deps;
}
}
public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
return this.applyWithWalker(
new EnforceModuleBoundariesWalker(
sourceFile,
this.getOptions(),
this.projectPath,
this.npmScope,
this.projectNodes,
this.deps
)
);
}
private readCliConfig(projectPath: string): any {
return JSON.parse(
readFileSync(`${projectPath}/.angular-cli.json`, 'UTF-8')
);
}
}
type DepConstraint = {
sourceTag: string;
onlyDependOnLibsWithTags: string[];
};
class EnforceModuleBoundariesWalker extends Lint.RuleWalker {
private readonly allow: string[];
private readonly depConstraints: DepConstraint[];
constructor(
sourceFile: ts.SourceFile,
options: IOptions,
private readonly projectPath: string,
private readonly npmScope: string,
private readonly projectNodes: ProjectNode[],
private readonly deps: { [projectName: string]: Dependency[] }
) {
super(sourceFile, options);
this.projectNodes.sort((a, b) => {
if (!a.name) return -1;
if (!b.name) return -1;
return a.name.length > b.name.length ? -1 : 1;
});
this.allow = Array.isArray(this.getOptions()[0].allow)
? this.getOptions()[0].allow.map(a => `${a}`)
: [];
this.depConstraints = Array.isArray(this.getOptions()[0].depConstraints)
? this.getOptions()[0].depConstraints
: [];
}
public visitImportDeclaration(node: ts.ImportDeclaration) {
const imp = node.moduleSpecifier
.getText()
.substring(1, node.moduleSpecifier.getText().length - 1);
// whitelisted import
if (this.allow.indexOf(imp) > -1) {
super.visitImportDeclaration(node);
return;
}
// check for relative and absolute imports
if (
this.isRelativeImportIntoAnotherProject(imp) ||
this.isAbsoluteImportIntoAnotherProject(imp)
) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
`library imports must start with @${this.npmScope}/`
);
return;
}
// 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
// something went wrong => return.
if (!sourceProject || !targetProject) {
super.visitImportDeclaration(node);
return;
}
// check for circular dependency
if (this.isCircular(sourceProject, targetProject)) {
const error = `Circular dependency between "${
sourceProject.name
}" and "${targetProject.name}" detected`;
this.addFailureAt(node.getStart(), node.getWidth(), error);
return;
}
// same project => allow
if (sourceProject === targetProject) {
super.visitImportDeclaration(node);
return;
}
// cannot import apps
if (targetProject.type === ProjectType.app) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
'imports of apps are forbidden'
);
return;
}
// deep imports aren't allowed
if (imp !== `@${this.npmScope}/${targetProject.name}`) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
'deep imports into libraries are forbidden'
);
return;
}
// if we import a library using loadChildre, we should not import it using es6imports
if (this.onlyLoadChildren(sourceProject.name, targetProject.name, [])) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
'imports of lazy-loaded libraries are forbidden'
);
return;
}
// check that dependency constraints are satisfied
if (this.depConstraints.length > 0) {
const constraint = this.findConstraintFor(sourceProject);
// when no constrains found => error. Force the user to provision them.
if (!constraint) {
this.addFailureAt(
node.getStart(),
node.getWidth(),
`A project without tags cannot depend on any libraries`
);
return;
}
if (
hasNoneOfTheseTags(
targetProject,
constraint.onlyDependOnLibsWithTags || []
)
) {
const allowedTags = constraint.onlyDependOnLibsWithTags
.map(s => `"${s}"`)
.join(', ');
const error = `A project tagged with "${
constraint.sourceTag
}" can only depend on libs tagged with ${allowedTags}`;
this.addFailureAt(node.getStart(), node.getWidth(), error);
return;
}
}
}
super.visitImportDeclaration(node);
}
private isCircular(
sourceProject: ProjectNode,
targetProject: ProjectNode
): boolean {
if (!this.deps[targetProject.name]) return false;
return this.deps[targetProject.name].some(
dep => dep.projectName == sourceProject.name
);
}
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 = path
.resolve(
path.join(this.projectPath, path.dirname(this.getSourceFilePath())),
imp
)
.split(path.sep)
.join('/')
.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(path.join(targetFile, '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 => unscopedImport === n.name || unscopedImport.startsWith(`${n.name}/`)
)[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 findConstraintFor(sourceProject: ProjectNode) {
return this.depConstraints.filter(f =>
hasTag(sourceProject, f.sourceTag)
)[0];
}
}
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(/\.[^/.]+$/, '');
}