dpdm
Version:
Analyze circular dependencies in your JavaScript/TypeScript projects.
444 lines (407 loc) • 11.7 kB
text/typescript
/*!
* Copyright 2019 acrazing <joking.young@gmail.com>. All rights reserved.
* @since 2019-07-17 18:45:32
*/
import chalk from 'chalk';
import fs from 'fs-extra';
import { builtinModules } from 'module';
import path from 'path';
import { DependencyKind } from './consts';
import { Dependency, DependencyTree, ParseOptions } from './types';
const allBuiltins = new Set(builtinModules);
export type SkippedImport = readonly [string, string];
function createSkippedImportsRegExp(
skipImports: readonly SkippedImport[],
): RegExp {
if (skipImports.length === 0) {
return /$./;
}
return new RegExp(
`^(?:${skipImports.map((item) => item.join(':')).join('|')})$`,
);
}
export const defaultOptions: ParseOptions = {
cwd: process.cwd(),
context: process.cwd(),
extensions: ['', '.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
js: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
include: /.*/,
exclude: /node_modules/,
tsconfig: void 0,
transform: false,
skipDynamicImports: false,
onProgress: () => void 0,
};
export function normalizeOptions(options: Partial<ParseOptions>): ParseOptions {
const newOptions = { ...defaultOptions, ...options };
newOptions.cwd = path.resolve(options.cwd || process.cwd());
newOptions.context = path.resolve(newOptions.cwd, options.context || '.');
if (newOptions.extensions.indexOf('') < 0) {
newOptions.extensions.unshift('');
}
if (options.tsconfig === void 0) {
try {
const tsconfig = path.join(newOptions.context, 'tsconfig.json');
const stat = fs.statSync(tsconfig);
if (stat.isFile()) {
newOptions.tsconfig = tsconfig;
}
} catch {}
} else {
const tsconfig = path.resolve(newOptions.cwd, options.tsconfig);
let stat: fs.Stats | undefined;
try {
stat = fs.statSync(tsconfig);
} catch {}
if (!stat || !stat.isFile()) {
throw new Error(`specified tsconfig "${options.tsconfig}" is not a file`);
}
newOptions.tsconfig = tsconfig;
}
return newOptions;
}
export async function appendSuffix(
request: string,
extensions: string[],
): Promise<string | null> {
for (const ext of extensions) {
try {
const stat = await fs.stat(request + ext);
if (stat.isFile()) {
return request + ext;
}
} catch {}
}
try {
const stat = await fs.stat(request);
if (stat.isDirectory()) {
return appendSuffix(path.join(request, 'index'), extensions);
}
} catch {}
return null;
}
export type Resolver = (
context: string,
request: string,
extensions: string[],
) => Promise<string | null>;
export const simpleResolver: Resolver = async (
context: string,
request: string,
extensions: string[],
) => {
if (path.isAbsolute(request)) {
return appendSuffix(request, extensions);
}
if (request.charAt(0) === '.') {
return appendSuffix(path.join(context, request), extensions);
}
// is package
const nodePath = { paths: [context] };
try {
const pkgPath = require.resolve(
path.join(request, 'package.json'),
nodePath,
);
const pkgJson = await fs.readJSON(pkgPath);
const id = path.join(path.dirname(pkgPath), pkgJson.module || pkgJson.main);
return appendSuffix(id, extensions);
} catch {}
try {
return require.resolve(request, nodePath);
} catch {}
return null;
};
export function shortenTree(
context: string,
tree: DependencyTree,
): DependencyTree {
const output: DependencyTree = {};
for (const key in tree) {
const shortKey = path.relative(context, key);
output[shortKey] = tree[key]
? tree[key]!.map(
(item) =>
({
...item,
issuer: shortKey,
id: item.id === null ? null : path.relative(context, item.id),
}) as Dependency,
)
: null;
}
return output;
}
function getPackageNameFromRequest(request: string): string | null {
if (request.startsWith('.') || path.isAbsolute(request)) {
return null;
}
const parts = request.split('/');
if (request.startsWith('@')) {
return parts.length > 1 ? parts.slice(0, 2).join('/') : request;
}
return parts[0] || null;
}
function getPackageNameFromPath(
context: string,
id: string,
cache: Map<string, string | null>,
): string | null {
if (allBuiltins.has(id)) {
return id;
}
const fullPath = path.isAbsolute(id) ? id : path.resolve(context, id);
let current = path.extname(fullPath) ? path.dirname(fullPath) : fullPath;
const root = path.parse(current).root;
while (true) {
const cached = cache.get(current);
if (cached !== void 0) {
return cached;
}
try {
const pkg = fs.readJSONSync(path.join(current, 'package.json'));
const name =
typeof pkg.name === 'string' && pkg.name
? pkg.name
: path.relative(context, current) || path.basename(current);
cache.set(current, name);
return name;
} catch {}
if (current === root) {
cache.set(current, null);
return null;
}
current = path.dirname(current);
}
}
export function getPackageName(context: string, id: string): string | null {
return getPackageNameFromPath(context, id, new Map());
}
export function groupDependencyTreeByPackage(
tree: DependencyTree,
context: string,
): DependencyTree {
const packages: Record<string, Dependency[] | null> = {};
const edges: Record<string, Set<string>> = {};
const cache = new Map<string, string | null>();
function ensurePackage(id: string, ignored = false) {
if (!(id in packages)) {
packages[id] = ignored ? null : [];
} else if (packages[id] === null && !ignored) {
packages[id] = [];
}
}
for (const id in tree) {
const issuerPackage = getPackageNameFromPath(context, id, cache) || id;
const deps = tree[id];
ensurePackage(issuerPackage, deps === null);
if (!deps) {
continue;
}
for (const dep of deps) {
const dependencyPackage = dep.id
? getPackageNameFromPath(context, dep.id, cache)
: getPackageNameFromRequest(dep.request);
if (!dependencyPackage || dependencyPackage === issuerPackage) {
continue;
}
ensurePackage(dependencyPackage, dep.id ? tree[dep.id] === null : false);
const edgeSet = (edges[issuerPackage] =
edges[issuerPackage] || new Set());
if (edgeSet.has(dependencyPackage)) {
continue;
}
edgeSet.add(dependencyPackage);
(packages[issuerPackage] as Dependency[]).push({
issuer: issuerPackage,
request: dep.request,
kind: dep.kind,
id: dependencyPackage,
});
}
}
for (const id in packages) {
packages[id]?.sort((a, b) => a.id!.localeCompare(b.id!));
}
return packages;
}
export function groupEntriesByPackage(
entries: string[],
context: string,
): string[] {
const output: string[] = [];
const seen = new Set<string>();
const cache = new Map<string, string | null>();
for (const entry of entries) {
const id = getPackageNameFromPath(context, entry, cache) || entry;
if (!seen.has(id)) {
output.push(id);
seen.add(id);
}
}
return output;
}
export function parseCircular(
tree: DependencyTree,
skipDynamicImports: boolean = false,
skipImports: readonly SkippedImport[] = [],
): string[][] {
const circulars: string[][] = [];
const skippedImports = createSkippedImportsRegExp(skipImports);
tree = { ...tree };
function visit(id: string, used: string[]) {
const index = used.indexOf(id);
if (index > -1) {
circulars.push(used.slice(index));
} else if (tree[id]) {
used.push(id);
const deps = tree[id];
delete tree[id];
deps &&
deps.forEach((dep) => {
if (
dep.id &&
(!skipDynamicImports ||
dep.kind !== DependencyKind.DynamicImport) &&
!skippedImports.test(`${dep.issuer}:${dep.id}`)
) {
visit(dep.id, used.slice());
}
});
}
}
for (const id in tree) {
visit(id, []);
}
return circulars;
}
export function parseDependents(
tree: DependencyTree,
): Record<string, string[]> {
const output: Record<string, string[]> = {};
for (const key in tree) {
const deps = tree[key];
if (deps) {
deps.forEach((dep) => {
if (dep.id) {
(output[dep.id] = output[dep.id] || []).push(key);
}
});
}
}
for (const key in output) {
output[key].sort();
}
return output;
}
export function parseWarnings(
tree: DependencyTree,
dependents = parseDependents(tree),
): string[] {
const warnings: string[] = [];
const builtin = new Set<string>();
for (const key in tree) {
const deps = tree[key];
if (!builtin.has(key) && allBuiltins.has(key)) {
builtin.add(key);
}
if (!deps) {
const parents = dependents[key] || [];
const total = parents.length;
warnings.push(
`skip ${JSON.stringify(key)}, issuers: ${parents
.slice(0, 2)
.map((id) => JSON.stringify(id))
.join(', ')}${total > 2 ? ` (${total - 2} more...)` : ''}`,
);
} else {
for (const dep of deps) {
if (!dep.id) {
warnings.push(
`miss ${JSON.stringify(dep.request)} in ${JSON.stringify(
dep.issuer,
)}`,
);
}
}
}
}
if (builtin.size > 0) {
warnings.push(
'node ' + Array.from(builtin, (item) => JSON.stringify(item)).join(', '),
);
}
return warnings.sort();
}
export function prettyTree(
tree: DependencyTree,
entries: string[],
prefix = ' ',
) {
const lines: string[] = [];
let id = 0;
const idMap: Record<string, number> = {};
const digits = Math.ceil(Math.log10(Object.keys(tree).length));
function visit(item: string, prefix: string, hasMore: boolean) {
const isNew = idMap[item] === void 0;
const iid = (idMap[item] = idMap[item] || id++);
let line = chalk.gray(
prefix + '- ' + iid.toString().padStart(digits, '0') + ') ',
);
const deps = tree[item];
if (allBuiltins.has(item)) {
lines.push(line + chalk.blue(item));
return;
} else if (!isNew) {
lines.push(line + chalk.gray(item));
return;
} else if (!deps) {
lines.push(line + chalk.yellow(item));
return;
}
lines.push(line + item);
prefix += hasMore ? '· ' : ' ';
for (let i = 0; i < deps.length; i++) {
visit(deps[i].id || deps[i].request, prefix, i < deps.length - 1);
}
}
for (let i = 0; i < entries.length; i++) {
visit(entries[i], prefix, i < entries.length - 1);
}
return lines.join('\n');
}
export function prettyCircular(circulars: string[][], prefix = ' ') {
const digits = Math.ceil(Math.log10(circulars.length));
return circulars
.map((line, index) => {
return (
chalk.gray(
`${prefix}${(index + 1).toString().padStart(digits, '0')}) `,
) + line.map((item) => chalk.red(item)).join(chalk.gray(' -> '))
);
})
.join('\n');
}
export function prettyWarning(warnings: string[], prefix = ' ') {
const digits = Math.ceil(Math.log10(warnings.length));
return warnings
.map((line, index) => {
return (
chalk.gray(
`${prefix}${(index + 1).toString().padStart(digits, '0')}) `,
) + chalk.yellow(line)
);
})
.join('\n');
}
export function isEmpty(v: unknown) {
if (v == null) {
return true;
}
for (const k in v) {
if (v.hasOwnProperty(k)) {
return false;
}
}
return true;
}