UNPKG

dpdm

Version:

Analyze circular dependencies in your JavaScript/TypeScript projects.

444 lines (407 loc) 11.7 kB
/*! * 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; }