322 lines
9.2 KiB
TypeScript
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(/\.[^/.]+$/, '');
|
|
}
|