dpdm 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bin/dpdm.ts CHANGED
@@ -14,6 +14,8 @@ import { parseDependencyTree } from '../parser';
14
14
  import { ParseOptions } from '../types';
15
15
  import {
16
16
  defaultOptions,
17
+ groupDependencyTreeByPackage,
18
+ groupEntriesByPackage,
17
19
  isEmpty,
18
20
  parseCircular,
19
21
  parseWarnings,
@@ -34,6 +36,10 @@ function normalizeCircularId(context: string, id: string) {
34
36
  return path.relative(context, fullPath);
35
37
  }
36
38
 
39
+ function resolveFromCwd(cwd: string, target: string): string {
40
+ return path.resolve(cwd, target);
41
+ }
42
+
37
43
  function parseSkipImports(
38
44
  skipImports: string[],
39
45
  context: string,
@@ -70,7 +76,11 @@ async function main() {
70
76
  )
71
77
  .option('context', {
72
78
  type: 'string',
73
- desc: 'the context directory to shorten path, default is current directory',
79
+ desc: 'the context directory to shorten path, default is cwd',
80
+ })
81
+ .option('cwd', {
82
+ type: 'string',
83
+ desc: 'the working directory used to match files and resolve relative paths, default is current directory',
74
84
  })
75
85
  .option('extensions', {
76
86
  alias: 'ext',
@@ -149,6 +159,11 @@ async function main() {
149
159
  array: true,
150
160
  desc: 'Skip import edges from circular checks. Values are regexp ISSUER:DEPENDENCY pairs.',
151
161
  })
162
+ .option('group-by-package', {
163
+ type: 'boolean',
164
+ desc: 'print dependencies and circulars grouped by nearest package.json',
165
+ default: false,
166
+ })
152
167
  .alias('h', 'help')
153
168
  .wrap(Math.min(y.terminalWidth(), 100))
154
169
  .parseAsync();
@@ -182,7 +197,8 @@ async function main() {
182
197
  let ended = 0;
183
198
  let current = '';
184
199
 
185
- const context = argv.context || process.cwd();
200
+ const cwd = path.resolve((argv.cwd as string | undefined) || process.cwd());
201
+ const context = path.resolve(cwd, argv.context || '.');
186
202
  const skippedImports = parseSkipImports(
187
203
  ((argv.skipImports as string[] | undefined) || []).flatMap(
188
204
  splitSkipImportValue,
@@ -207,6 +223,7 @@ async function main() {
207
223
  }
208
224
 
209
225
  const options: ParseOptions = {
226
+ cwd,
210
227
  context,
211
228
  extensions: argv.extensions.split(','),
212
229
  js: argv.js.split(','),
@@ -224,39 +241,54 @@ async function main() {
224
241
  throw new Error(`No entry files were matched.`);
225
242
  }
226
243
  o.succeed(`[${ended}/${total}] Analyze done!`);
227
- const entriesDeep = await Promise.all(files.map((g) => G.glob(g)));
244
+ const entriesDeep = await Promise.all(
245
+ files.map((g) => G.glob(g, { cwd })),
246
+ );
228
247
  const entries = await Promise.all(
229
248
  Array<string>()
230
249
  .concat(...entriesDeep)
231
- .map((name) =>
232
- simpleResolver(
233
- options.context!,
234
- path.join(options.context!, name),
235
- options.extensions,
236
- ).then((id) => (id ? path.relative(options.context!, id) : name)),
237
- ),
250
+ .map((name) => {
251
+ const fullName = resolveFromCwd(cwd, name);
252
+ return simpleResolver(cwd, fullName, options.extensions).then(
253
+ (id) => path.relative(context, id || fullName),
254
+ );
255
+ }),
238
256
  );
257
+ const displayedTree = argv.groupByPackage
258
+ ? groupDependencyTreeByPackage(tree, context)
259
+ : tree;
260
+ const displayedEntries = argv.groupByPackage
261
+ ? groupEntriesByPackage(entries, context)
262
+ : entries;
239
263
  const circulars = parseCircular(
240
- tree,
264
+ displayedTree,
241
265
  argv.skipDynamicImports === 'circular',
242
266
  skippedImports,
243
267
  );
244
268
  if (argv.output) {
245
269
  await fs.outputJSON(
246
270
  argv.output,
247
- { entries, tree, circulars },
271
+ { entries: displayedEntries, tree: displayedTree, circulars },
248
272
  { spaces: 2 },
249
273
  );
250
274
  }
251
275
  if (argv.tree) {
252
- console.log(chalk.bold('• Dependencies Tree'));
253
- console.log(prettyTree(tree, entries));
276
+ console.log(
277
+ chalk.bold(
278
+ argv.groupByPackage
279
+ ? '• Package Dependencies Tree'
280
+ : '• Dependencies Tree',
281
+ ),
282
+ );
283
+ console.log(prettyTree(displayedTree, displayedEntries));
254
284
  console.log('');
255
285
  }
256
286
  if (argv.circular) {
257
287
  console.log(
258
288
  chalk.bold[circulars.length === 0 ? 'green' : 'red'](
259
- '• Circular Dependencies',
289
+ argv.groupByPackage
290
+ ? '• Package Circular Dependencies'
291
+ : '• Circular Dependencies',
260
292
  ),
261
293
  );
262
294
  if (circulars.length === 0) {
@@ -276,8 +308,10 @@ async function main() {
276
308
  console.log('');
277
309
  }
278
310
  if (argv.detectUnusedFilesFrom) {
279
- const allFiles = await G.glob(argv.detectUnusedFilesFrom);
280
- const shortAllFiles = allFiles.map((v) => path.relative(context, v));
311
+ const allFiles = await G.glob(argv.detectUnusedFilesFrom, { cwd });
312
+ const shortAllFiles = allFiles.map((v) =>
313
+ path.relative(context, resolveFromCwd(cwd, v)),
314
+ );
281
315
  const unusedFiles = shortAllFiles.filter((v) => !(v in tree)).sort();
282
316
  console.log(chalk.bold.cyan('• Unused files'));
283
317
  if (unusedFiles.length === 0) {
@@ -0,0 +1,134 @@
1
+ /*!
2
+ * Copyright 2019 acrazing <joking.young@gmail.com>. All rights reserved.
3
+ * @since 2026-05-09 14:35:00
4
+ */
5
+
6
+ import path from 'path';
7
+ import { DependencyKind } from './consts';
8
+ import { parseDependencyTree } from './parser';
9
+ import {
10
+ groupDependencyTreeByPackage,
11
+ groupEntriesByPackage,
12
+ parseCircular,
13
+ } from './utils';
14
+
15
+ describe('parser', () => {
16
+ const fixture = path.join(__dirname, '../fixtures/parser/monorepo');
17
+
18
+ it('should parse relative entries from a custom cwd', async () => {
19
+ const tree = await parseDependencyTree('packages/shared/src/index.ts', {
20
+ cwd: fixture,
21
+ });
22
+
23
+ expect(tree).toEqual({
24
+ 'packages/shared/src/index.ts': [
25
+ {
26
+ issuer: 'packages/shared/src/index.ts',
27
+ request: './dep',
28
+ kind: DependencyKind.StaticExport,
29
+ id: 'packages/shared/src/dep.ts',
30
+ },
31
+ ],
32
+ 'packages/shared/src/dep.ts': [],
33
+ });
34
+ });
35
+
36
+ it('should parse an absolute entry file path', async () => {
37
+ const tree = await parseDependencyTree(
38
+ path.join(fixture, 'packages/shared/src/index.ts'),
39
+ {
40
+ context: fixture,
41
+ },
42
+ );
43
+
44
+ expect(tree).toEqual({
45
+ 'packages/shared/src/index.ts': [
46
+ {
47
+ issuer: 'packages/shared/src/index.ts',
48
+ request: './dep',
49
+ kind: DependencyKind.StaticExport,
50
+ id: 'packages/shared/src/dep.ts',
51
+ },
52
+ ],
53
+ 'packages/shared/src/dep.ts': [],
54
+ });
55
+ });
56
+
57
+ it('should resolve aliases from an absolute tsconfig path', async () => {
58
+ const tree = await parseDependencyTree(
59
+ path.join(fixture, 'packages/alias-user/src/index.ts'),
60
+ {
61
+ context: fixture,
62
+ tsconfig: path.join(fixture, 'tsconfig.json'),
63
+ },
64
+ );
65
+
66
+ expect(tree['packages/alias-user/src/index.ts']).toEqual([
67
+ {
68
+ issuer: 'packages/alias-user/src/index.ts',
69
+ request: '~/dep',
70
+ kind: DependencyKind.StaticImport,
71
+ id: 'packages/shared/src/dep.ts',
72
+ },
73
+ ]);
74
+ });
75
+
76
+ it('should group dependencies and circulars by package', async () => {
77
+ const tree = await parseDependencyTree(
78
+ ['packages/app/src/index.ts', 'packages/ui/src/index.ts'],
79
+ { cwd: fixture },
80
+ );
81
+ const packageTree = groupDependencyTreeByPackage(tree, fixture);
82
+
83
+ expect(
84
+ groupEntriesByPackage(
85
+ [
86
+ 'packages/app/src/index.ts',
87
+ 'packages/ui/src/index.ts',
88
+ 'packages/app/src/local.ts',
89
+ ],
90
+ fixture,
91
+ ),
92
+ ).toEqual(['@repo/app', '@repo/ui']);
93
+ expect(packageTree).toEqual({
94
+ '@repo/app': [
95
+ {
96
+ issuer: '@repo/app',
97
+ request: '../../shared/src',
98
+ kind: DependencyKind.StaticImport,
99
+ id: '@repo/shared',
100
+ },
101
+ {
102
+ issuer: '@repo/app',
103
+ request: '../../ui/src',
104
+ kind: DependencyKind.StaticImport,
105
+ id: '@repo/ui',
106
+ },
107
+ ],
108
+ '@repo/ui': [
109
+ {
110
+ issuer: '@repo/ui',
111
+ request: '../../shared/src',
112
+ kind: DependencyKind.StaticImport,
113
+ id: '@repo/shared',
114
+ },
115
+ ],
116
+ '@repo/shared': [],
117
+ });
118
+ expect(parseCircular(packageTree)).toEqual([]);
119
+ });
120
+
121
+ it('should detect package-level circular dependencies', async () => {
122
+ const tree = await parseDependencyTree(
123
+ ['packages/cycle-a/src/index.ts', 'packages/cycle-b/src/index.ts'],
124
+ {
125
+ cwd: fixture,
126
+ },
127
+ );
128
+ const packageTree = groupDependencyTreeByPackage(tree, fixture);
129
+
130
+ const circulars = parseCircular(packageTree);
131
+ expect(circulars).toHaveLength(1);
132
+ expect(circulars[0].sort()).toEqual(['@repo/cycle-a', '@repo/cycle-b']);
133
+ });
134
+ });
package/src/parser.ts CHANGED
@@ -134,15 +134,14 @@ export async function parseDependencyTree(
134
134
  if (!Array.isArray(entries)) {
135
135
  entries = [entries];
136
136
  }
137
- const currentDirectory = process.cwd();
138
137
  const output: DependencyTree = {};
139
138
  const fullOptions = normalizeOptions(options);
140
139
  let resolve = simpleResolver;
141
- if (options.tsconfig) {
140
+ if (fullOptions.tsconfig) {
142
141
  const compilerOptions = ts.parseJsonConfigFileContent(
143
- ts.readConfigFile(options.tsconfig, ts.sys.readFile).config,
142
+ ts.readConfigFile(fullOptions.tsconfig, ts.sys.readFile).config,
144
143
  ts.sys,
145
- path.dirname(options.tsconfig),
144
+ path.dirname(fullOptions.tsconfig),
146
145
  ).options;
147
146
 
148
147
  const host = ts.createCompilerHost(compilerOptions);
@@ -170,12 +169,12 @@ export async function parseDependencyTree(
170
169
  }
171
170
  await Promise.all(
172
171
  entries.map((entry) =>
173
- G.glob(entry).then((matches) =>
172
+ G.glob(entry, { cwd: fullOptions.cwd }).then((matches) =>
174
173
  Promise.all(
175
174
  matches.map((filename) =>
176
175
  parseTreeRecursive(
177
- currentDirectory,
178
- path.join(currentDirectory, filename),
176
+ fullOptions.cwd,
177
+ path.resolve(fullOptions.cwd, filename),
179
178
  fullOptions,
180
179
  output,
181
180
  resolve,
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { DependencyKind } from './consts';
7
7
 
8
8
  export interface ParseOptions {
9
+ cwd: string;
9
10
  context: string;
10
11
  extensions: string[];
11
12
  js: string[];
package/src/utils.ts CHANGED
@@ -26,6 +26,7 @@ function createSkippedImportsRegExp(
26
26
  }
27
27
 
28
28
  export const defaultOptions: ParseOptions = {
29
+ cwd: process.cwd(),
29
30
  context: process.cwd(),
30
31
  extensions: ['', '.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
31
32
  js: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
@@ -39,27 +40,29 @@ export const defaultOptions: ParseOptions = {
39
40
 
40
41
  export function normalizeOptions(options: Partial<ParseOptions>): ParseOptions {
41
42
  const newOptions = { ...defaultOptions, ...options };
43
+ newOptions.cwd = path.resolve(options.cwd || process.cwd());
44
+ newOptions.context = path.resolve(newOptions.cwd, options.context || '.');
42
45
  if (newOptions.extensions.indexOf('') < 0) {
43
46
  newOptions.extensions.unshift('');
44
47
  }
45
- newOptions.context = path.resolve(newOptions.context);
46
48
  if (options.tsconfig === void 0) {
47
49
  try {
48
50
  const tsconfig = path.join(newOptions.context, 'tsconfig.json');
49
51
  const stat = fs.statSync(tsconfig);
50
52
  if (stat.isFile()) {
51
- options.tsconfig = tsconfig;
53
+ newOptions.tsconfig = tsconfig;
52
54
  }
53
55
  } catch {}
54
56
  } else {
57
+ const tsconfig = path.resolve(newOptions.cwd, options.tsconfig);
55
58
  let stat: fs.Stats | undefined;
56
59
  try {
57
- stat = fs.statSync(options.tsconfig);
60
+ stat = fs.statSync(tsconfig);
58
61
  } catch {}
59
62
  if (!stat || !stat.isFile()) {
60
63
  throw new Error(`specified tsconfig "${options.tsconfig}" is not a file`);
61
64
  }
62
- options.tsconfig = path.join(process.cwd(), options.tsconfig);
65
+ newOptions.tsconfig = tsconfig;
63
66
  }
64
67
  return newOptions;
65
68
  }
@@ -140,6 +143,128 @@ export function shortenTree(
140
143
  return output;
141
144
  }
142
145
 
146
+ function getPackageNameFromRequest(request: string): string | null {
147
+ if (request.startsWith('.') || path.isAbsolute(request)) {
148
+ return null;
149
+ }
150
+ const parts = request.split('/');
151
+ if (request.startsWith('@')) {
152
+ return parts.length > 1 ? parts.slice(0, 2).join('/') : request;
153
+ }
154
+ return parts[0] || null;
155
+ }
156
+
157
+ function getPackageNameFromPath(
158
+ context: string,
159
+ id: string,
160
+ cache: Map<string, string | null>,
161
+ ): string | null {
162
+ if (allBuiltins.has(id)) {
163
+ return id;
164
+ }
165
+ const fullPath = path.isAbsolute(id) ? id : path.resolve(context, id);
166
+ let current = path.extname(fullPath) ? path.dirname(fullPath) : fullPath;
167
+ const root = path.parse(current).root;
168
+
169
+ while (true) {
170
+ const cached = cache.get(current);
171
+ if (cached !== void 0) {
172
+ return cached;
173
+ }
174
+
175
+ try {
176
+ const pkg = fs.readJSONSync(path.join(current, 'package.json'));
177
+ const name =
178
+ typeof pkg.name === 'string' && pkg.name
179
+ ? pkg.name
180
+ : path.relative(context, current) || path.basename(current);
181
+ cache.set(current, name);
182
+ return name;
183
+ } catch {}
184
+
185
+ if (current === root) {
186
+ cache.set(current, null);
187
+ return null;
188
+ }
189
+ current = path.dirname(current);
190
+ }
191
+ }
192
+
193
+ export function getPackageName(context: string, id: string): string | null {
194
+ return getPackageNameFromPath(context, id, new Map());
195
+ }
196
+
197
+ export function groupDependencyTreeByPackage(
198
+ tree: DependencyTree,
199
+ context: string,
200
+ ): DependencyTree {
201
+ const packages: Record<string, Dependency[] | null> = {};
202
+ const edges: Record<string, Set<string>> = {};
203
+ const cache = new Map<string, string | null>();
204
+
205
+ function ensurePackage(id: string, ignored = false) {
206
+ if (!(id in packages)) {
207
+ packages[id] = ignored ? null : [];
208
+ } else if (packages[id] === null && !ignored) {
209
+ packages[id] = [];
210
+ }
211
+ }
212
+
213
+ for (const id in tree) {
214
+ const issuerPackage = getPackageNameFromPath(context, id, cache) || id;
215
+ const deps = tree[id];
216
+ ensurePackage(issuerPackage, deps === null);
217
+ if (!deps) {
218
+ continue;
219
+ }
220
+
221
+ for (const dep of deps) {
222
+ const dependencyPackage = dep.id
223
+ ? getPackageNameFromPath(context, dep.id, cache)
224
+ : getPackageNameFromRequest(dep.request);
225
+ if (!dependencyPackage || dependencyPackage === issuerPackage) {
226
+ continue;
227
+ }
228
+
229
+ ensurePackage(dependencyPackage, dep.id ? tree[dep.id] === null : false);
230
+ const edgeSet = (edges[issuerPackage] =
231
+ edges[issuerPackage] || new Set());
232
+ if (edgeSet.has(dependencyPackage)) {
233
+ continue;
234
+ }
235
+ edgeSet.add(dependencyPackage);
236
+ (packages[issuerPackage] as Dependency[]).push({
237
+ issuer: issuerPackage,
238
+ request: dep.request,
239
+ kind: dep.kind,
240
+ id: dependencyPackage,
241
+ });
242
+ }
243
+ }
244
+
245
+ for (const id in packages) {
246
+ packages[id]?.sort((a, b) => a.id!.localeCompare(b.id!));
247
+ }
248
+ return packages;
249
+ }
250
+
251
+ export function groupEntriesByPackage(
252
+ entries: string[],
253
+ context: string,
254
+ ): string[] {
255
+ const output: string[] = [];
256
+ const seen = new Set<string>();
257
+ const cache = new Map<string, string | null>();
258
+ for (const entry of entries) {
259
+ const id = getPackageNameFromPath(context, entry, cache) || entry;
260
+ if (!seen.has(id)) {
261
+ output.push(id);
262
+ seen.add(id);
263
+ }
264
+ }
265
+ return output;
266
+ }
267
+
143
268
  export function parseCircular(
144
269
  tree: DependencyTree,
145
270
  skipDynamicImports: boolean = false,