feat(nx): support to generate visualization graph

`npm run dep-graph` outputs a visual dependency graph
This commit is contained in:
Nitin Vericherla 2018-04-04 20:27:00 -04:00 committed by Victor Savkin
parent eca52599d9
commit 49525efe3e
18 changed files with 665 additions and 60 deletions

View File

@ -286,4 +286,136 @@ describe('Command line', () => {
}, },
1000000 1000000
); );
describe('dep-graph', () => {
beforeEach(() => {
newProject();
newApp('myapp');
newApp('myapp2');
newApp('myapp3');
newLib('mylib');
newLib('mylib2');
updateFile(
'apps/myapp/src/main.ts',
`
import '@proj/mylib';
const s = {loadChildren: '@proj/mylib2'};
`
);
updateFile(
'apps/myapp2/src/app/app.component.spec.ts',
`import '@proj/mylib';`
);
updateFile(
'libs/mylib/src/mylib.module.spec.ts',
`import '@proj/mylib2';`
);
});
it(
'dep-graph should output json (without critical path) to file',
() => {
const file = 'dep-graph.json';
runCommand(`npm run dep-graph -- --file="${file}"`);
expect(() => checkFilesExist(file)).not.toThrow();
const jsonFileContents = readJson(file);
expect(jsonFileContents).toEqual({
deps: {
mylib2: [],
myapp3: [],
myapp2: [
{
projectName: 'mylib',
type: 'es6Import'
}
],
mylib: [
{
projectName: 'mylib2',
type: 'es6Import'
}
],
myapp: [
{
projectName: 'mylib',
type: 'es6Import'
},
{
projectName: 'mylib2',
type: 'loadChildren'
}
]
},
criticalPath: []
});
},
1000000
);
it(
'dep-graph should output json with critical path to file',
() => {
const file = 'dep-graph.json';
runCommand(
`npm run affected:dep-graph -- --files="libs/mylib/index.ts" --file="${file}"`
);
expect(() => checkFilesExist(file)).not.toThrow();
const jsonFileContents = readJson(file);
expect(jsonFileContents.criticalPath).toContain('myapp');
expect(jsonFileContents.criticalPath).toContain('myapp2');
expect(jsonFileContents.criticalPath).toContain('mylib');
expect(jsonFileContents.criticalPath).not.toContain('mylib2');
},
1000000
);
it(
'dep-graph should output dot to file',
() => {
const file = 'dep-graph.dot';
runCommand(
`npm run dep-graph -- --files="libs/mylib/index.ts" --file="${file}"`
);
expect(() => checkFilesExist(file)).not.toThrow();
const fileContents = readFile(file);
expect(fileContents).toContain('"myapp" -> "mylib"');
expect(fileContents).toContain('"myapp2" -> "mylib"');
expect(fileContents).toContain('"mylib" -> "mylib2"');
},
1000000
);
it(
'dep-graph should output html to file',
() => {
const file = 'dep-graph.html';
runCommand(
`npm run dep-graph -- --files="libs/mylib/index.ts" --file="${file}"`
);
expect(() => checkFilesExist(file)).not.toThrow();
const fileContents = readFile(file);
expect(fileContents).toContain('<html>');
expect(fileContents).toContain('<title>myapp&#45;&gt;mylib</title>');
expect(fileContents).toContain('<title>myapp&#45;&gt;mylib2</title>');
expect(fileContents).toContain('<title>mylib&#45;&gt;mylib2</title>');
},
1000000
);
});
}); });

View File

@ -14,8 +14,7 @@
"copy": "./scripts/copy.sh", "copy": "./scripts/copy.sh",
"test:schematics": "yarn linknpm fast && ./scripts/test_schematics.sh", "test:schematics": "yarn linknpm fast && ./scripts/test_schematics.sh",
"test:nx": "yarn linknpm fast && ./scripts/test_nx.sh", "test:nx": "yarn linknpm fast && ./scripts/test_nx.sh",
"test": "test": "yarn linknpm fast && ./scripts/test_nx.sh && ./scripts/test_schematics.sh",
"yarn linknpm fast && ./scripts/test_nx.sh && ./scripts/test_schematics.sh",
"checkformat": "prettier \"{packages,e2e}/**/*.ts\" --list-different", "checkformat": "prettier \"{packages,e2e}/**/*.ts\" --list-different",
"publish_npm": "./scripts/publish.sh" "publish_npm": "./scripts/publish.sh"
}, },
@ -44,6 +43,7 @@
"angular": "1.6.6", "angular": "1.6.6",
"app-root-path": "^2.0.1", "app-root-path": "^2.0.1",
"cosmiconfig": "^4.0.0", "cosmiconfig": "^4.0.0",
"graphviz": "^0.0.8",
"husky": "^0.14.3", "husky": "^0.14.3",
"jasmine-core": "~2.8.0", "jasmine-core": "~2.8.0",
"jasmine-spec-reporter": "~4.2.1", "jasmine-spec-reporter": "~4.2.1",
@ -52,8 +52,9 @@
"karma-chrome-launcher": "~2.2.0", "karma-chrome-launcher": "~2.2.0",
"karma-jasmine": "~1.1.0", "karma-jasmine": "~1.1.0",
"karma-webpack": "2.0.4", "karma-webpack": "2.0.4",
"npm-run-all": "4.1.2",
"ng-packagr": "2.2.0", "ng-packagr": "2.2.0",
"npm-run-all": "4.1.2",
"opn": "^5.3.0",
"precise-commits": "1.0.0", "precise-commits": "1.0.0",
"prettier": "1.10.2", "prettier": "1.10.2",
"rxjs": "^5.5.6", "rxjs": "^5.5.6",
@ -62,6 +63,7 @@
"tmp": "0.0.33", "tmp": "0.0.33",
"tslint": "5.9.1", "tslint": "5.9.1",
"typescript": "2.6.2", "typescript": "2.6.2",
"viz.js": "^1.8.1",
"yargs-parser": "9.0.2", "yargs-parser": "9.0.2",
"zone.js": "^0.8.19", "zone.js": "^0.8.19",
"fs-extra": "5.0.0" "fs-extra": "5.0.0"

View File

@ -12,12 +12,14 @@
"affected:apps": "./node_modules/.bin/nx affected apps", "affected:apps": "./node_modules/.bin/nx affected apps",
"affected:build": "./node_modules/.bin/nx affected build", "affected:build": "./node_modules/.bin/nx affected build",
"affected:e2e": "./node_modules/.bin/nx affected e2e", "affected:e2e": "./node_modules/.bin/nx affected e2e",
"affected:dep-graph": "./node_modules/.bin/nx affected dep-graph",
"format": "./node_modules/.bin/nx format write", "format": "./node_modules/.bin/nx format write",
"format:write": "./node_modules/.bin/nx format write", "format:write": "./node_modules/.bin/nx format write",
"format:check": "./node_modules/.bin/nx format check", "format:check": "./node_modules/.bin/nx format check",
"update": "./node_modules/.bin/nx update", "update": "./node_modules/.bin/nx update",
"update:check": "./node_modules/.bin/nx update check", "update:check": "./node_modules/.bin/nx update check",
"update:skip": "./node_modules/.bin/nx update skip", "update:skip": "./node_modules/.bin/nx update skip",
"dep-graph": "./node_modules/.bin/nx dep-graph",
"postinstall": "ngc -p ngc.tsconfig.json" "postinstall": "ngc -p ngc.tsconfig.json"
}, },
"private": true, "private": true,

View File

@ -1,6 +1,10 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
export function writeToFile(path: string, str: string) {
fs.writeFileSync(path, str);
}
/** /**
* This method is specifically for updating a JSON file using the filesystem * This method is specifically for updating a JSON file using the filesystem
* *
@ -12,7 +16,7 @@ import * as path from 'path';
export function updateJsonFile(path: string, callback: (a: any) => any) { export function updateJsonFile(path: string, callback: (a: any) => any) {
const json = readJsonFile(path); const json = readJsonFile(path);
callback(json); callback(json);
fs.writeFileSync(path, JSON.stringify(json, null, 2)); writeToFile(path, JSON.stringify(json, null, 2));
} }
export function addApp(apps: any[] | undefined, newApp: any): any[] { export function addApp(apps: any[] | undefined, newApp: any): any[] {
@ -59,3 +63,17 @@ export function copyFile(file: string, target: string) {
source.pipe(dest); source.pipe(dest);
source.on('error', e => console.error(e)); source.on('error', e => console.error(e));
} }
function directoryExists(name) {
try {
return fs.statSync(name).isDirectory();
} catch (e) {
return false;
}
}
export function createDirectory(name: string) {
if (!directoryExists(name)) {
fs.mkdirSync(name);
}
}

View File

@ -0,0 +1,14 @@
import { updateJsonFile } from '../src/utils/fileutils';
export default {
description: 'Update npm scripts to use the nx command',
run: () => {
updateJsonFile('package.json', json => {
json.scripts = {
...json.scripts,
'dep-graph': './node_modules/.bin/nx dep-graph',
'affected:dep-graph': './node_modules/.bin/nx affected dep-graph'
};
});
}
};

View File

@ -20,7 +20,7 @@
}, },
"main": "index.js", "main": "index.js",
"types": "index.d.js", "types": "index.d.js",
"peerDependencies": { }, "peerDependencies": {},
"author": "Victor Savkin", "author": "Victor Savkin",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
@ -32,9 +32,12 @@
"@ngrx/schematics": "5.2.0", "@ngrx/schematics": "5.2.0",
"@schematics/angular": "0.4.6", "@schematics/angular": "0.4.6",
"app-root-path": "^2.0.1", "app-root-path": "^2.0.1",
"graphviz": "0.0.8",
"npm-run-all": "4.1.2", "npm-run-all": "4.1.2",
"opn": "^5.3.0",
"semver": "5.4.1", "semver": "5.4.1",
"tmp": "0.0.33", "tmp": "0.0.33",
"viz.js": "^1.8.1",
"yargs-parser": "9.0.2" "yargs-parser": "9.0.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -9,20 +9,18 @@
"test": "ng test", "test": "ng test",
"lint": "./node_modules/.bin/nx lint && ng lint", "lint": "./node_modules/.bin/nx lint && ng lint",
"e2e": "ng e2e", "e2e": "ng e2e",
"affected:apps": "./node_modules/.bin/nx affected apps", "affected:apps": "./node_modules/.bin/nx affected apps",
"affected:build": "./node_modules/.bin/nx affected build", "affected:build": "./node_modules/.bin/nx affected build",
"affected:e2e": "./node_modules/.bin/nx affected e2e", "affected:e2e": "./node_modules/.bin/nx affected e2e",
"affected:dep-graph": "./node_modules/.bin/nx affected dep-graph",
"format": "./node_modules/.bin/nx format write", "format": "./node_modules/.bin/nx format write",
"format:write": "./node_modules/.bin/nx format write", "format:write": "./node_modules/.bin/nx format write",
"format:check": "./node_modules/.bin/nx format check", "format:check": "./node_modules/.bin/nx format check",
"update": "./node_modules/.bin/nx update", "update": "./node_modules/.bin/nx update",
"update:check": "./node_modules/.bin/nx update check", "update:check": "./node_modules/.bin/nx update check",
"update:skip": "./node_modules/.bin/nx update skip", "update:skip": "./node_modules/.bin/nx update skip",
"workspace-schematic": "./node_modules/.bin/nx workspace-schematic", "workspace-schematic": "./node_modules/.bin/nx workspace-schematic",
"dep-graph": "./node_modules/.bin/nx dep-graph",
"postinstall": "./node_modules/.bin/nx postinstall" "postinstall": "./node_modules/.bin/nx postinstall"
}, },
"private": true, "private": true,
@ -43,7 +41,8 @@
"@ngrx/router-store": "<%= routerStoreVersion %>", "@ngrx/router-store": "<%= routerStoreVersion %>",
"@ngrx/store": "<%= ngrxVersion %>", "@ngrx/store": "<%= ngrxVersion %>",
"@ngrx/store-devtools": "<%= ngrxVersion %>", "@ngrx/store-devtools": "<%= ngrxVersion %>",
"ngrx-store-freeze": "<%= ngrxStoreFreezeVersion %>" "ngrx-store-freeze": "<%= ngrxStoreFreezeVersion %>",
"viz": "^0.0.1"
}, },
"devDependencies": { "devDependencies": {
"@angular/cli": "<%= angularCliVersion %>", "@angular/cli": "<%= angularCliVersion %>",
@ -64,7 +63,8 @@
"karma-jasmine-html-reporter": "^0.2.2", "karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~5.1.2", "protractor": "~5.1.2",
"ts-node": "~4.1.0", "ts-node": "~4.1.0",
"tslint": "~5.9.1",<% } %> "tslint": "~5.9.1",<%
} %>
"typescript": "<%= typescriptVersion %>", "typescript": "<%= typescriptVersion %>",
"prettier": "<%= prettierVersion %>" "prettier": "<%= prettierVersion %>"
} }

View File

@ -85,6 +85,9 @@ function updatePackageJson() {
'./node_modules/.bin/nx affected build'; './node_modules/.bin/nx affected build';
packageJson.scripts['affected:e2e'] = './node_modules/.bin/nx affected e2e'; packageJson.scripts['affected:e2e'] = './node_modules/.bin/nx affected e2e';
packageJson.scripts['affected:dep-graph'] =
'./node_modules/.bin/nx affected dep-graph';
packageJson.scripts['format'] = './node_modules/.bin/nx format write'; packageJson.scripts['format'] = './node_modules/.bin/nx format write';
packageJson.scripts['format:write'] = './node_modules/.bin/nx format write'; packageJson.scripts['format:write'] = './node_modules/.bin/nx format write';
packageJson.scripts['format:check'] = './node_modules/.bin/nx format check'; packageJson.scripts['format:check'] = './node_modules/.bin/nx format check';
@ -94,6 +97,9 @@ function updatePackageJson() {
packageJson.scripts['update:skip'] = './node_modules/.bin/nx update skip'; packageJson.scripts['update:skip'] = './node_modules/.bin/nx update skip';
packageJson.scripts['lint'] = './node_modules/.bin/nx lint && ng lint'; packageJson.scripts['lint'] = './node_modules/.bin/nx lint && ng lint';
packageJson.scripts['dep-graph'] = './node_modules/.bin/nx dep-graph';
packageJson.scripts['postinstall'] = './node_modules/.bin/nx postinstall'; packageJson.scripts['postinstall'] = './node_modules/.bin/nx postinstall';
packageJson.scripts['workspace-schematic'] = packageJson.scripts['workspace-schematic'] =
'./node_modules/.bin/nx workspace-schematic'; './node_modules/.bin/nx workspace-schematic';

View File

@ -1,5 +1,5 @@
import { import {
affectedApps, affectedAppNames,
dependencies, dependencies,
DependencyType, DependencyType,
ProjectType, ProjectType,
@ -254,9 +254,9 @@ describe('Calculates Dependencies Between Apps and Libs', () => {
}); });
}); });
describe('affectedApps', () => { describe('affectedAppNames', () => {
it('should return the list of affected files', () => { it('should return the list of affected files', () => {
const affected = affectedApps( const affected = affectedAppNames(
'nrwl', 'nrwl',
[ [
{ {
@ -309,7 +309,7 @@ describe('Calculates Dependencies Between Apps and Libs', () => {
}); });
it('should return app app names if a touched file is not part of a project', () => { it('should return app app names if a touched file is not part of a project', () => {
const affected = affectedApps( const affected = affectedAppNames(
'nrwl', 'nrwl',
[ [
{ {
@ -353,7 +353,7 @@ describe('Calculates Dependencies Between Apps and Libs', () => {
}); });
it('should handle slashes', () => { it('should handle slashes', () => {
const affected = affectedApps( const affected = affectedAppNames(
'nrwl', 'nrwl',
[ [
{ {
@ -379,7 +379,7 @@ describe('Calculates Dependencies Between Apps and Libs', () => {
}); });
it('should handle circular dependencies', () => { it('should handle circular dependencies', () => {
const affected = affectedApps( const affected = affectedAppNames(
'nrwl', 'nrwl',
[ [
{ {

View File

@ -8,7 +8,8 @@ export enum ProjectType {
export enum DependencyType { export enum DependencyType {
es6Import = 'es6Import', es6Import = 'es6Import',
loadChildren = 'loadChildren' loadChildren = 'loadChildren',
implicit = 'implicit'
} }
export type ProjectNode = { export type ProjectNode = {
@ -32,23 +33,51 @@ export function touchedProjects(
}); });
} }
export function affectedApps( function affectedProjects(
npmScope: string,
projects: ProjectNode[],
fileRead: (s: string) => string,
touchedFiles: string[]
): ProjectNode[] {
projects = normalizeProjects(projects);
const deps = dependencies(npmScope, projects, fileRead);
const tp = touchedProjects(projects, touchedFiles);
if (tp.indexOf(null) > -1) {
return projects;
} else {
return projects.filter(proj =>
hasDependencyOnTouchedProjects(proj.name, tp, deps, [])
);
}
}
export type AffectedFetcher = (
npmScope: string,
projects: ProjectNode[],
fileRead: (s: string) => string,
touchedFiles: string[]
) => string[];
export function affectedAppNames(
npmScope: string, npmScope: string,
projects: ProjectNode[], projects: ProjectNode[],
fileRead: (s: string) => string, fileRead: (s: string) => string,
touchedFiles: string[] touchedFiles: string[]
): string[] { ): string[] {
projects = normalizeProjects(projects); return affectedProjects(npmScope, projects, fileRead, touchedFiles)
const deps = dependencies(npmScope, projects, fileRead);
const tp = touchedProjects(projects, touchedFiles);
if (tp.indexOf(null) > -1) {
return projects.filter(p => p.type === ProjectType.app).map(p => p.name);
} else {
return projects
.filter(p => p.type === ProjectType.app) .filter(p => p.type === ProjectType.app)
.map(p => p.name) .map(p => p.name);
.filter(name => hasDependencyOnTouchedProjects(name, tp, deps, [])); }
}
export function affectedProjectNames(
npmScope: string,
projects: ProjectNode[],
fileRead: (s: string) => string,
touchedFiles: string[]
): string[] {
return affectedProjects(npmScope, projects, fileRead, touchedFiles).map(
p => p.name
);
} }
function hasDependencyOnTouchedProjects( function hasDependencyOnTouchedProjects(
@ -86,16 +115,18 @@ function normalizeFiles(files: string[]): string[] {
return files.map(f => f.replace(/[\\\/]+/g, '/')); return files.map(f => f.replace(/[\\\/]+/g, '/'));
} }
export type Deps = { [projectName: string]: Dependency[] };
export function dependencies( export function dependencies(
npmScope: string, npmScope: string,
projects: ProjectNode[], projects: ProjectNode[],
fileRead: (s: string) => string fileRead: (s: string) => string
): { [projectName: string]: Dependency[] } { ): Deps {
return new Deps(npmScope, projects, fileRead).calculateDeps(); return new DepsCalculator(npmScope, projects, fileRead).calculateDeps();
} }
class Deps { class DepsCalculator {
private deps: { [projectName: string]: Dependency[] }; private deps: Deps;
constructor( constructor(
private npmScope: string, private npmScope: string,

View File

@ -1,18 +1,21 @@
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { getAffectedApps, parseFiles } from './shared'; import { getAffectedApps, getAffectedProjects, parseFiles } from './shared';
import * as path from 'path'; import * as path from 'path';
import * as resolve from 'resolve'; import * as resolve from 'resolve';
import * as runAll from 'npm-run-all'; import * as runAll from 'npm-run-all';
import * as yargsParser from 'yargs-parser'; import * as yargsParser from 'yargs-parser';
import { generateGraph } from './dep-graph';
export function affected(args: string[]): void { export function affected(args: string[]): void {
const command = args[0]; const command = args[0];
let apps: string[]; let apps: string[];
let projects: string[];
let rest: string[]; let rest: string[];
try { try {
const p = parseFiles(args.slice(1)); const p = parseFiles(args.slice(1));
rest = p.rest; rest = p.rest;
apps = getAffectedApps(p.files); apps = getAffectedApps(p.files);
projects = getAffectedProjects(p.files);
} catch (e) { } catch (e) {
printError(command, e); printError(command, e);
process.exit(1); process.exit(1);
@ -28,6 +31,9 @@ export function affected(args: string[]): void {
case 'e2e': case 'e2e':
e2e(apps, rest); e2e(apps, rest);
break; break;
case 'dep-graph':
generateGraph(yargsParser(rest), projects);
break;
} }
} }
@ -39,7 +45,7 @@ function printError(command: string, e: any) {
`Or pass the list of files, as follows: npm run affected:${command} -- --files="libs/mylib/index.ts,libs/mylib2/index.ts".` `Or pass the list of files, as follows: npm run affected:${command} -- --files="libs/mylib/index.ts,libs/mylib2/index.ts".`
); );
console.error( console.error(
`Or to get the list of files from local changes: npm run affected:${command} -- --uncommitted | --untracked".` `Or to get the list of files from local changes: npm run affected:${command} -- --uncommitted | --untracked.`
); );
console.error(e.message); console.error(e.message);
} }

View File

@ -0,0 +1,5 @@
describe('dep-graph', () => {
describe('getNodeProps', () => {
it('should get highlighted props for critical path', () => {});
});
});

View File

@ -0,0 +1,346 @@
import { writeToFile } from '../utils/fileutils';
import * as graphviz from 'graphviz';
import * as appRoot from 'app-root-path';
import * as opn from 'opn';
import { readFileSync } from 'fs';
import {
ProjectNode,
ProjectType,
dependencies,
Deps,
Dependency,
DependencyType
} from './affected-apps';
import { readCliConfig, getProjectNodes } from './shared';
import * as path from 'path';
import { tmpNameSync } from 'tmp';
const viz = require('viz.js'); // typings are incorrect in viz.js library - need to use `require`
export enum NodeEdgeVariant {
default = 'default',
highlighted = 'highlighted'
}
export type GraphvizOptions = {
fontname?: string;
fontsize?: number;
shape?: string;
color?: string;
style?: string;
fillcolor?: string;
};
export type AttrValue = {
attr: string;
value: boolean | number | string;
};
export type GraphvizOptionNodeEdge = {
[key: string]: {
[variant: string]: GraphvizOptions;
};
};
export type GraphvizConfig = {
graph: AttrValue[];
nodes: GraphvizOptionNodeEdge;
edges: GraphvizOptionNodeEdge;
};
export type ProjectMap = {
[name: string]: ProjectNode;
};
export type CriticalPathMap = {
[name: string]: boolean;
};
export enum OutputType {
'json' = 'json',
'html' = 'html',
'dot' = 'dot'
}
export type UserOptions = {
file?: string;
output?: string;
files?: string;
};
type ParsedUserOptions = {
isFilePresent?: boolean;
filename?: string;
type?: string;
output?: string;
shouldOpen: boolean;
};
type OutputOptions = {
data: string;
shouldOpen: boolean;
shouldWriteToFile: boolean;
filename?: string;
};
type JSONOutput = {
deps: Deps;
criticalPath: string[];
};
const defaultConfig = {
isFilePresent: false,
filename: undefined,
type: OutputType.html,
shouldOpen: true
};
export const graphvizConfig: GraphvizConfig = {
graph: [
{
attr: 'overlap',
value: false
},
{
attr: 'pad',
value: 0.111
}
],
nodes: {
[ProjectType.app]: {
[NodeEdgeVariant.default]: {
fontname: 'Arial',
fontsize: 14,
shape: 'box'
},
[NodeEdgeVariant.highlighted]: {
fontname: 'Arial',
fontsize: 14,
shape: 'box',
color: '#FF0033'
}
},
[ProjectType.lib]: {
[NodeEdgeVariant.default]: {
fontname: 'Arial',
fontsize: 14,
style: 'filled',
fillcolor: '#EFEFEF'
},
[NodeEdgeVariant.highlighted]: {
fontname: 'Arial',
fontsize: 14,
style: 'filled',
fillcolor: '#EFEFEF',
color: '#FF0033'
}
}
},
edges: {
[DependencyType.es6Import]: {
[NodeEdgeVariant.default]: {
color: '#757575'
},
[NodeEdgeVariant.highlighted]: {
color: '#FF0033'
}
},
[DependencyType.loadChildren]: {
[NodeEdgeVariant.default]: {
color: '#757575',
style: 'dotted'
},
[NodeEdgeVariant.highlighted]: {
color: '#FF0033',
style: 'dotted'
}
},
[DependencyType.implicit]: {
[NodeEdgeVariant.default]: {
color: '#000000',
style: 'bold'
},
[NodeEdgeVariant.highlighted]: {
color: '#FF0033',
style: 'bold'
}
}
}
};
function mapProjectNodes(projects: ProjectNode[]) {
return projects.reduce((m, proj) => ({ ...m, [proj.name]: proj }), {});
}
function getVariant(map: CriticalPathMap, key: string) {
return map[key] ? NodeEdgeVariant.highlighted : NodeEdgeVariant.default;
}
function getNodeProps(
config: GraphvizOptionNodeEdge,
projectNode: ProjectNode,
criticalPath: CriticalPathMap
) {
const nodeProps = config[projectNode.type];
return nodeProps[getVariant(criticalPath, projectNode.name)];
}
function getEdgeProps(
config: GraphvizOptionNodeEdge,
depType: DependencyType,
child: string,
criticalPath: CriticalPathMap
) {
const edgeProps = config[depType];
return edgeProps[getVariant(criticalPath, child)];
}
export function createGraphviz(
config: GraphvizConfig,
deps: Deps,
projects: ProjectNode[],
criticalPath: CriticalPathMap
) {
const projectMap: ProjectMap = mapProjectNodes(projects);
const g = graphviz.digraph('G');
config.graph.forEach(({ attr, value }) => g.set(attr, value));
Object.keys(deps)
.sort() // sorting helps with testing
.forEach(key => {
const projectNode = projectMap[key];
const dependencies = deps[key];
g.addNode(key, getNodeProps(config.nodes, projectNode, criticalPath));
if (dependencies.length > 0) {
dependencies.forEach((dep: Dependency, i: number) => {
g.addNode(
dep.projectName,
getNodeProps(config.nodes, projectNode, criticalPath)
); // child node
g.addEdge(
key,
dep.projectName,
getEdgeProps(config.edges, dep.type, dep.projectName, criticalPath)
);
});
}
});
return g.to_dot();
}
function handleOutput({
data,
shouldOpen,
shouldWriteToFile,
filename
}: OutputOptions) {
if (shouldOpen) {
const tmpFilename = `${tmpNameSync()}.html`;
writeToFile(tmpFilename, data);
opn(tmpFilename, {
wait: false
});
} else if (!shouldWriteToFile) {
return console.log(data);
} else {
writeToFile(filename, data);
}
}
function applyHTMLTemplate(svg: string) {
return `<!DOCTYPE html>
<html>
<head><title></title></head>
<body>${svg}</body>
</html>
`;
}
function generateGraphJson(criticalPath?: string[]): JSONOutput {
const config = readCliConfig();
const npmScope = config.project.npmScope;
const projects: ProjectNode[] = getProjectNodes(config);
// fetch all apps and libs
const deps = dependencies(npmScope, projects, f =>
readFileSync(`${appRoot.path}/${f}`, 'utf-8')
);
return {
deps,
criticalPath
};
}
function getDot(json: JSONOutput) {
const config = readCliConfig();
const projects: ProjectNode[] = getProjectNodes(config);
return createGraphviz(
graphvizConfig,
json.deps,
projects,
json.criticalPath.reduce((m, proj) => ({ ...m, [proj]: true }), {})
);
}
function getConfigFromUserInput(cmdOpts: UserOptions): ParsedUserOptions {
const filename = cmdOpts.file;
const output = cmdOpts.output;
if (filename && output) {
throw new Error(
'Received both filename as well as output type. Please only specify one of the options.'
);
}
const extension = !!filename
? path.extname(filename).substring(1)
: output || OutputType.html;
return {
isFilePresent: !output,
type: extension,
output: output,
shouldOpen: !output && !filename,
filename
};
}
function extractDataFromJson(json, type) {
switch (type) {
case OutputType.json:
return JSON.stringify(json, null, 2);
case OutputType.dot:
return getDot(json);
case OutputType.html:
return applyHTMLTemplate(viz(getDot(json)));
default:
throw new Error(
'Unrecognized file extension. Supported extensions are "json", "html", and "dot"'
);
}
}
export function generateGraph(
args: UserOptions,
criticalPath?: string[]
): void {
const json = generateGraphJson(criticalPath || []);
const config = {
...defaultConfig,
...getConfigFromUserInput(args)
};
handleOutput({
data: extractDataFromJson(json, config.type),
filename: config.filename,
shouldWriteToFile: config.isFilePresent,
shouldOpen: config.shouldOpen
});
}

View File

@ -7,6 +7,7 @@ import { update } from './update';
import { patchNg } from './patch-ng'; import { patchNg } from './patch-ng';
import { lint } from './lint'; import { lint } from './lint';
import { workspaceSchematic } from './workspace-schematic'; import { workspaceSchematic } from './workspace-schematic';
import { generateGraph } from './dep-graph';
const processedArgs = yargsParser(process.argv, { const processedArgs = yargsParser(process.argv, {
alias: { alias: {
@ -21,6 +22,9 @@ switch (command) {
case 'affected': case 'affected':
affected(args); affected(args);
break; break;
case 'dep-graph':
generateGraph(yargsParser(args));
break;
case 'format': case 'format':
format(args); format(args);
break; break;

View File

@ -1,16 +1,16 @@
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import * as path from 'path'; import * as path from 'path';
import { import {
affectedApps, AffectedFetcher,
affectedAppNames,
ProjectNode, ProjectNode,
ProjectType, ProjectType,
touchedProjects touchedProjects,
dependencies,
Dependency,
affectedProjectNames
} from './affected-apps'; } from './affected-apps';
import * as fs from 'fs'; import * as fs from 'fs';
import {
dependencies,
Dependency
} from '@nrwl/schematics/src/command-line/affected-apps';
import { statSync } from 'fs'; import { statSync } from 'fs';
import * as appRoot from 'app-root-path'; import * as appRoot from 'app-root-path';
import { readJsonFile } from '../utils/fileutils'; import { readJsonFile } from '../utils/fileutils';
@ -110,17 +110,22 @@ export function readCliConfig(): any {
return config; return config;
} }
export function getAffectedApps(touchedFiles: string[]): string[] { export const getAffected = (affectedNamesFetcher: AffectedFetcher) => (
touchedFiles: string[]
): string[] => {
const config = readCliConfig(); const config = readCliConfig();
const projects = getProjectNodes(config); const projects = getProjectNodes(config);
return affectedApps( return affectedNamesFetcher(
config.project.npmScope, config.project.npmScope,
projects, projects,
f => fs.readFileSync(`${appRoot.path}/${f}`, 'utf-8'), f => fs.readFileSync(`${appRoot.path}/${f}`, 'utf-8'),
touchedFiles touchedFiles
); );
} };
export const getAffectedApps = getAffected(affectedAppNames);
export const getAffectedProjects = getAffected(affectedProjectNames);
export function getTouchedProjects(touchedFiles: string[]): string[] { export function getTouchedProjects(touchedFiles: string[]): string[] {
return touchedProjects(getProjectNodes(readCliConfig()), touchedFiles).filter( return touchedProjects(getProjectNodes(readCliConfig()), touchedFiles).filter(

View File

@ -1,6 +1,10 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
export function writeToFile(path: string, str: string) {
fs.writeFileSync(path, str);
}
/** /**
* This method is specifically for updating a JSON file using the filesystem * This method is specifically for updating a JSON file using the filesystem
* *
@ -12,7 +16,7 @@ import * as path from 'path';
export function updateJsonFile(path: string, callback: (a: any) => any) { export function updateJsonFile(path: string, callback: (a: any) => any) {
const json = readJsonFile(path); const json = readJsonFile(path);
callback(json); callback(json);
fs.writeFileSync(path, serializeJson(json)); writeToFile(path, serializeJson(json));
} }
export function addApp(apps: any[] | undefined, newApp: any): any[] { export function addApp(apps: any[] | undefined, newApp: any): any[] {
@ -58,3 +62,17 @@ export function copyFile(file: string, target: string) {
source.pipe(dest); source.pipe(dest);
source.on('error', e => console.error(e)); source.on('error', e => console.error(e));
} }
function directoryExists(name) {
try {
return fs.statSync(name).isDirectory();
} catch (e) {
return false;
}
}
export function createDirectory(name: string) {
if (!directoryExists(name)) {
fs.mkdirSync(name);
}
}

View File

@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
npm run build
./scripts/link.sh ./scripts/link.sh
rm -rf tmp rm -rf tmp
mkdir tmp mkdir tmp

View File

@ -3017,6 +3017,12 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6:
version "4.1.11" version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
graphviz@^0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/graphviz/-/graphviz-0.0.8.tgz#e599e40733ef80e1653bfe89a5f031ecf2aa4aaa"
dependencies:
temp "~0.4.0"
growly@^1.3.0: growly@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
@ -5263,7 +5269,7 @@ onetime@^2.0.0:
dependencies: dependencies:
mimic-fn "^1.0.0" mimic-fn "^1.0.0"
opn@^5.1.0: opn@^5.1.0, opn@^5.3.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/opn/-/opn-5.3.0.tgz#64871565c863875f052cfdf53d3e3cb5adb53b1c" resolved "https://registry.yarnpkg.com/opn/-/opn-5.3.0.tgz#64871565c863875f052cfdf53d3e3cb5adb53b1c"
dependencies: dependencies:
@ -7153,6 +7159,10 @@ tar@^2.0.0, tar@^2.2.1:
fstream "^1.0.2" fstream "^1.0.2"
inherits "2" inherits "2"
temp@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/temp/-/temp-0.4.0.tgz#671ad63d57be0fe9d7294664b3fc400636678a60"
term-size@^1.2.0: term-size@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
@ -7615,6 +7625,10 @@ verror@1.10.0:
core-util-is "1.0.2" core-util-is "1.0.2"
extsprintf "^1.2.0" extsprintf "^1.2.0"
viz.js@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/viz.js/-/viz.js-1.8.1.tgz#277ab3cf4367c608a95b281a7472083c3e2ee6cf"
vlq@^0.2.1, vlq@^0.2.2: vlq@^0.2.1, vlq@^0.2.2:
version "0.2.3" version "0.2.3"
resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"