Skip to content

Commit 9470a6e

Browse files
committed
feat: enhance deps-graph command with collapse and exclude options for improved dependency visualization, close #46
1 parent b69d496 commit 9470a6e

7 files changed

Lines changed: 266 additions & 52 deletions

File tree

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ You need to configure the scripts in the package.json:
4141
- `scripts.prepack` is **recommended** to ensure that all files are up-to-date before releasing. Here you can build code and documentation.
4242
- `scripts.release` is **recommended** to make it easy to release a new version.
4343

44+
# Trimming a noisy dependency graph
45+
46+
For repos with high fan-out, `deps-graph` accepts repeatable globs to collapse or drop nodes:
47+
48+
```bash
49+
# Merge all region scrapers into a single labelled node ("regions/*.ts (24 files)").
50+
vrt deps-graph --collapse-dir 'src/regions/*.ts'
51+
52+
# Drop trivial barrels and stubs from the graph entirely.
53+
vrt deps-graph --exclude '**/_planned.ts' --exclude '**/index.ts'
54+
```
55+
4456
# Command `vrt`
4557

4658
<!--- This chapter is generated automatically --->
@@ -58,7 +70,7 @@ Options:
5870

5971
Commands:
6072
check Check repo for required scripts and other stuff.
61-
deps-graph Analyze project files and output a dependency graph as Mermaid markup.
73+
deps-graph [options] Analyze project files and output a dependency graph as Mermaid markup.
6274
deps-upgrade Upgrade all dependencies in the current project to their latest versions.
6375
doc-command <command> Generate Markdown documentation for a specified command and output the result.
6476
doc-insert <readme> [heading] [foldable] Insert Markdown from stdin into a specified section of a Markdown file.
@@ -89,7 +101,11 @@ Usage: vrt deps-graph [options]
89101
Analyze project files and output a dependency graph as Mermaid markup.
90102

91103
Options:
92-
-h, --help display help for command
104+
--collapse-dir <glob> Collapse all files matching the glob into a single node
105+
(repeatable). (default: [])
106+
--exclude <glob> Drop files matching the glob from the graph entirely
107+
(repeatable). (default: [])
108+
-h, --help display help for command
93109
```
94110

95111
## Subcommand: `vrt deps-upgrade`

package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@schemastore/package": "^1.0.5",
4545
"@types/mdast": "^4.0.4",
4646
"@types/node": "^25.6.0",
47+
"@types/picomatch": "^4.0.3",
4748
"@typescript-eslint/eslint-plugin": "^8.59.2",
4849
"@typescript-eslint/parser": "^8.59.2",
4950
"@vitest/coverage-v8": "^4.1.5",
@@ -62,6 +63,7 @@
6263
"dependency-cruiser": "^17.4.0",
6364
"mdast-util-to-markdown": "^2.1.2",
6465
"npm-check-updates": "^22.1.0",
66+
"picomatch": "^4.0.4",
6567
"remark": "^15.0.1",
6668
"remark-gfm": "^4.0.1",
6769
"remark-stringify": "^11.0.0",

src/commands/deps-graph.test.ts

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest';
2-
import type { IReporterOutput } from 'dependency-cruiser';
2+
import type { ICruiseResult, IModule, IReporterOutput } from 'dependency-cruiser';
33

4-
// 1. Mock dependency-cruiser to control the output of `cruise`
4+
// 1. Mock dependency-cruiser to control the output of `cruise` and `format`
55
vi.mock('dependency-cruiser', () => ({
66
cruise: vi.fn(),
7+
format: vi.fn(),
78
}));
89

910
// 2. Mock the log/panic module
@@ -14,70 +15,149 @@ vi.mock('../lib/log.js', () => ({
1415
}));
1516

1617
// 3. Import the mocked modules and the function under test
17-
const { cruise } = await import('dependency-cruiser');
18+
const { cruise, format } = await import('dependency-cruiser');
1819
const { panic } = await import('../lib/log.js');
1920
const { generateDependencyGraph } = await import('./deps-graph.js');
2021

22+
/** Build a minimal ICruiseResult with the given modules; all other fields stubbed. */
23+
function fakeCruiseResult(modules: Pick<IModule, 'source' | 'dependencies'>[]): ICruiseResult {
24+
return {
25+
modules: modules.map((m) => ({
26+
source: m.source,
27+
dependencies: m.dependencies,
28+
dependents: [],
29+
valid: true,
30+
})) as IModule[],
31+
summary: {} as ICruiseResult['summary'],
32+
};
33+
}
34+
2135
describe('generateDependencyGraph', () => {
2236
let mockStdoutWrite: MockInstance<typeof process.stdout.write>;
2337

2438
beforeEach(() => {
25-
// Reset and clear mocks before each test
2639
vi.clearAllMocks();
27-
28-
// Spy on process.stdout.write to check what gets written
2940
mockStdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
41+
42+
// Default: cruise returns the modules-shaped result the new code expects
43+
vi.mocked(cruise).mockResolvedValue({
44+
output: fakeCruiseResult([{ source: 'src/a.ts', dependencies: [] }]),
45+
} as IReporterOutput);
46+
47+
// Default: format returns a minimal mermaid string the post-processor can rewrite
48+
vi.mocked(format).mockResolvedValue({ output: 'flowchart LR\nA-->B' } as IReporterOutput);
3049
});
3150

3251
afterEach(() => {
33-
// Restore any spied methods
3452
mockStdoutWrite.mockRestore();
3553
});
3654

3755
it('generates a mermaid diagram, replacing flowchart LR with flowchart TB', async () => {
38-
// Mock a successful result from dependency-cruiser
39-
vi.mocked(cruise).mockResolvedValueOnce({
40-
output: 'flowchart LR\nA-->B',
41-
} as IReporterOutput);
42-
4356
await expect(generateDependencyGraph('src')).resolves.toBeUndefined();
4457

45-
// Verify the call to `cruise` used the expected arguments
46-
expect(cruise).toHaveBeenCalledWith(['src'], expect.any(Object));
58+
expect(cruise).toHaveBeenCalledWith(['src'], expect.objectContaining({ outputType: 'json' }));
4759

48-
// After modification, we expect flowchart TB in the output
4960
expect(mockStdoutWrite).toHaveBeenCalledTimes(1);
50-
// The actual argument passed to stdout.write
51-
const writtenBuffer = mockStdoutWrite.mock.calls[0][0] as Buffer;
52-
const writtenString = writtenBuffer.toString();
53-
61+
const writtenString = (mockStdoutWrite.mock.calls[0][0] as Buffer).toString();
5462
expect(writtenString).toMatch(/^```mermaid\n/);
5563
expect(writtenString).toMatch(/\nflowchart TB\nA-->B\n/);
5664
expect(writtenString).toMatch(/\n```\n$/);
5765
});
5866

5967
it('panics if dependency-cruiser throws an error', async () => {
60-
// Simulate a thrown error from `cruise`
6168
vi.mocked(cruise).mockImplementationOnce(async () => {
6269
throw new Error('Some error from dependency-cruiser');
6370
});
6471

6572
await expect(generateDependencyGraph('bad/path')).rejects.toThrow('Some error from dependency-cruiser');
6673

67-
// Confirm the panic function was called with the thrown error message
6874
expect(panic).toHaveBeenCalledWith('Error: Some error from dependency-cruiser');
69-
// No output should be written
7075
expect(mockStdoutWrite).not.toHaveBeenCalled();
7176
});
7277

73-
it('panics if the returned output is not a string', async () => {
74-
// Mock an invalid result (missing or incorrect `output` field)
75-
vi.mocked(cruise).mockResolvedValueOnce({
76-
output: null,
77-
} as unknown as IReporterOutput);
78+
it('panics if the formatted output is not a string', async () => {
79+
vi.mocked(format).mockResolvedValueOnce({ output: null } as unknown as IReporterOutput);
7880

7981
await expect(generateDependencyGraph('src')).rejects.toThrow('no output');
8082
expect(panic).toHaveBeenCalledWith('no output');
8183
expect(mockStdoutWrite).not.toHaveBeenCalled();
8284
});
85+
86+
describe('--exclude', () => {
87+
it('passes user-provided globs (as regex) to cruise alongside built-in excludes', async () => {
88+
await generateDependencyGraph('src', { exclude: ['**/_planned.ts'] });
89+
90+
const opts = vi.mocked(cruise).mock.calls[0][1];
91+
expect(opts).toBeDefined();
92+
const excludePatterns = (opts!.exclude ?? []) as string[];
93+
// built-in excludes are still present
94+
expect(excludePatterns).toContain('\\.(test|d|mock)\\.ts$');
95+
// user glob translated to a regex
96+
expect(excludePatterns.some((p) => /_planned/.test(p))).toBe(true);
97+
});
98+
});
99+
100+
describe('--collapse-dir', () => {
101+
it('merges files matching a glob into a single node and reports the count', async () => {
102+
vi.mocked(cruise).mockResolvedValueOnce({
103+
output: fakeCruiseResult([
104+
{
105+
source: 'src/regions/index.ts',
106+
dependencies: [
107+
{ resolved: 'src/regions/de.ts' },
108+
{ resolved: 'src/regions/fr.ts' },
109+
{ resolved: 'src/regions/it.ts' },
110+
{ resolved: 'src/regions/lib.ts' },
111+
] as IModule['dependencies'],
112+
},
113+
{
114+
source: 'src/regions/de.ts',
115+
dependencies: [{ resolved: 'src/regions/lib.ts' }] as IModule['dependencies'],
116+
},
117+
{
118+
source: 'src/regions/fr.ts',
119+
dependencies: [{ resolved: 'src/regions/lib.ts' }] as IModule['dependencies'],
120+
},
121+
{
122+
source: 'src/regions/it.ts',
123+
dependencies: [{ resolved: 'src/regions/lib.ts' }] as IModule['dependencies'],
124+
},
125+
{ source: 'src/regions/lib.ts', dependencies: [] },
126+
]),
127+
} as IReporterOutput);
128+
129+
await generateDependencyGraph('src', { collapseDir: ['src/regions/{de,fr,it}.ts'] });
130+
131+
// The collapsed result is what `format` receives.
132+
const formatArg = vi.mocked(format).mock.calls[0][0] as ICruiseResult;
133+
const sources = formatArg.modules.map((m) => m.source).sort();
134+
135+
// Original 5 files reduced to 3: index, lib, and one merged regions/{de,fr,it}.ts node
136+
expect(formatArg.modules).toHaveLength(3);
137+
expect(sources).toEqual(['src/regions/index.ts', 'src/regions/lib.ts', 'src/regions/{de,fr,it}.ts (3 files)']);
138+
139+
// Edges from index.ts: the three collapsed files become a single deduped edge to the merged node.
140+
const indexNode = formatArg.modules.find((m) => m.source === 'src/regions/index.ts')!;
141+
const indexResolved = indexNode.dependencies.map((d) => d.resolved).sort();
142+
expect(indexResolved).toEqual(['src/regions/lib.ts', 'src/regions/{de,fr,it}.ts (3 files)']);
143+
144+
// The merged node depends on lib.ts (deduped from the three originals) — and has no self-loop.
145+
const merged = formatArg.modules.find((m) => m.source.startsWith('src/regions/{de,fr,it}.ts'))!;
146+
expect(merged.dependencies.map((d) => d.resolved)).toEqual(['src/regions/lib.ts']);
147+
});
148+
149+
it('does nothing when no files match the collapse glob', async () => {
150+
vi.mocked(cruise).mockResolvedValueOnce({
151+
output: fakeCruiseResult([
152+
{ source: 'src/a.ts', dependencies: [{ resolved: 'src/b.ts' }] as IModule['dependencies'] },
153+
{ source: 'src/b.ts', dependencies: [] },
154+
]),
155+
} as IReporterOutput);
156+
157+
await generateDependencyGraph('src', { collapseDir: ['src/no-match/*.ts'] });
158+
159+
const formatArg = vi.mocked(format).mock.calls[0][0] as ICruiseResult;
160+
expect(formatArg.modules.map((m) => m.source).sort()).toEqual(['src/a.ts', 'src/b.ts']);
161+
});
162+
});
83163
});

0 commit comments

Comments
 (0)