Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added depth limit #318

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ Property | Type | Default | Description
`requireConfig` | String | null | RequireJS config for resolving aliased modules
`webpackConfig` | String | null | Webpack config for resolving aliased modules
`tsConfig` | String\|Object | null | TypeScript config for resolving aliased modules - Either a path to a tsconfig file or an object containing the config
`depth` | Number | null | Maximum dependency depth from source files to display
`layout` | String | dot | Layout to use in the graph
`rankdir` | String | LR | Sets the [direction](https://graphviz.gitlab.io/_pages/doc/info/attrs.html#d:rankdir) of the graph layout
`fontName` | String | Arial | Font name to use in the graph
Expand Down
12 changes: 12 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ program
.option('--require-config <file>', 'path to RequireJS config')
.option('--webpack-config <file>', 'path to webpack config')
.option('--ts-config <file>', 'path to typescript config')
.option('--depth <integer>', 'maximum depth from source files to draw')
.option('--include-npm', 'include shallow NPM modules', false)
.option('--no-color', 'disable color in output and image', false)
.option('--no-spinner', 'disable progress spinner', false)
Expand Down Expand Up @@ -113,6 +114,17 @@ if (program.tsConfig) {
config.tsConfig = program.tsConfig;
}

if (program.depth) {
config.depth = Number(program.depth);
}

if (config.depth) {
if (!Number.isInteger(config.depth) || config.depth < 0) {
console.log('%s %s', chalk.red('✖'), 'Invalid depth');
process.exit(1);
}
}

if (program.includeNpm) {
config.includeNpm = program.includeNpm;
}
Expand Down
1 change: 1 addition & 0 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const defaultConfig = {
requireConfig: null,
webpackConfig: null,
tsConfig: null,
depth: null,
rankdir: 'LR',
layout: 'dot',
fontName: 'Arial',
Expand Down
61 changes: 43 additions & 18 deletions lib/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,6 @@ class Tree {
const pathCache = {};

files.forEach((file) => {
if (visited[file]) {
return;
}

Object.assign(depTree, dependencyTree({
filename: file,
directory: this.baseDir,
Expand Down Expand Up @@ -148,7 +144,7 @@ class Tree {
}));
});

let tree = this.convertTree(depTree, {}, pathCache, npmPaths);
let tree = this.convertTree(visited, depTree, this.config.depth);

for (const npmKey in npmPaths) {
const id = this.processPath(npmKey, pathCache);
Expand All @@ -171,27 +167,56 @@ class Tree {
/**
* Convert deep tree produced by dependency-tree to a
* shallow (one level deep) tree used by madge.
* @param {Object} depTree
* @param {Object} modules
* @param {Object} tree
* @param {Object} pathCache
* @param {number} [depthLimit]
* @return {Object}
*/
convertTree(depTree, tree, pathCache) {
for (const key in depTree) {
const id = this.processPath(key, pathCache);

if (!tree[id]) {
tree[id] = [];

for (const dep in depTree[key]) {
tree[id].push(this.processPath(dep, pathCache));
convertTree(modules, tree, depthLimit) {
const self = this;
const depths = {};
const deepDependencies = {};

function calculateDepths(tree, depth) {
if (depth <= depthLimit) {
for (const dependency in tree) {
depths[dependency] = true;
calculateDepths(modules[dependency], depth + 1);
}
}
}

function getDeepDependencies(dependency) {
if (deepDependencies[dependency] === null) {
return [];
}

this.convertTree(depTree[key], tree, pathCache);
if (!(dependency in deepDependencies)) {
deepDependencies[dependency] = null;
deepDependencies[dependency] = [...new Set(Object.keys(modules[dependency]).flatMap(
(dependency) => dependency in depths ? [dependency] : getDeepDependencies(dependency)
))];
}

return deepDependencies[dependency];
}

const pathCache = {};
const result = {};

if (!Number.isInteger(depthLimit)) {
Object.entries(modules).forEach(([module, dependencies]) => {
result[self.processPath(module, pathCache)] = Object.keys(dependencies).map((dependency) => self.processPath(dependency, pathCache));
});
} else {
calculateDepths(tree, 0);

Object.keys(depths).forEach((module) => {
result[self.processPath(module, pathCache)] = getDeepDependencies(module).map((dependency) => self.processPath(dependency, pathCache));
});
}

return tree;
return result;
}

/**
Expand Down
48 changes: 48 additions & 0 deletions test/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,52 @@ describe('API', () => {
.catch(done);
});
});

describe('output depth limit', () => {
const baseDir = path.join(__dirname, 'deep-dependencies');
const entryFile = path.join(baseDir, 'a.js');

it('limits the output depth to 0', async () => {
const res = await madge(entryFile, {depth: 0});

res.obj().should.eql({
'a.js': []
});
});

it('limits the output depth to 1', async () => {
const res = await madge(entryFile, {depth: 1});

res.obj().should.eql({
'a.js': ['b.js', 'c.js'],
'b.js': [],
'c.js': ['b.js', 'c.js']
});
});

it('limits the output depth to 2', async () => {
const res = await madge(entryFile, {depth: 2});

res.obj().should.eql({
'a.js': ['b.js', 'c.js'],
'b.js': [],
'c.js': ['d.js', 'e.js'],
'd.js': [],
'e.js': ['b.js', 'c.js']
});
});

it('limits the output depth to 3', async () => {
const res = await madge(entryFile, {depth: 3});

res.obj().should.eql({
'a.js': ['b.js', 'c.js'],
'b.js': [],
'c.js': ['d.js', 'e.js'],
'd.js': [],
'e.js': ['f.js'],
'f.js': ['b.js', 'c.js']
});
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I apologize for my recent lack of activity. I'm currently sorting out a few details, as I've noticed that the implementation of C, E, F, C with depth=3 in the test file seems to induce a self-dependency, which isn't accounted for in the final test case (the highlighted one). I need a bit more time to fully understand this, as I initially thought this PR primarily involved output formatting.

Could you @keijokapp please provide a specific use case or example that illustrates the necessity of this functionality? It would greatly help in evaluating its impact and relevance.

Thank you for your understanding and support.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might not fully understand the question.

which isn't accounted for in the final test case (the highlighted one)

The output of depth=3 does show c -> e -> f -> c dependency cycle (?) So do tests with depth=1 and depth=2. In the context of this feature, there isn't anything special about circular dependencies. They are handled just like any other "back-references" (ie references from deep dependencies to non-deep dependencies). If there are circular dependencies in the output, it means there were circular dependency in the original graph. These tests make sure that they are handled (short-circuited) correctly.

please provide a specific use case or example that illustrates the necessity of this functionality

You mean "circular dependencies"? I don't think I'm the right person to explain the use cases for circular dependencies because I personally have a strong distaste for them. But they are nevertheless common and in rare cases not easily avoidable. This tool specifically handles them eg. by coloring them red on the dot graph. The feature doesn't have anything specifically to do with circular dependencies.


When it comes to "limiting the depth", there are two ways to go about it. One is to stop traversing at depth and ignore all references originating from that point onward. This is not usually what the user wants. Not showing those "back-references" gives a wrong impression about the dependency structure. For example, at depth=1, the user might see b.js and c.js as fully independent branches while, in fact, b.js is an indirect dependency of c.js.

Instead, as noted in the original submission, this feature traverses through the whole tree and does not hide those indirect relationships. It only removes the intermediate deep nodes.

I initially thought this PR primarily involved output formatting.

It really is just about the output. It is to remove the "noise" while keeping the useful relationship information. It allows the user to focus around the close proximity of specific modules. It doesn't change anything else about the tool (eg. module resolution, parsing, traversing).

Copy link
Collaborator

@PabloLION PabloLION May 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your detailed response, and I appreciate your patience as I navigate through this PR. After multiple reviews, I'm still grappling with some aspects of the implementation and would like to discuss these further.

  1. File Structure and Depth Concerns
    My apologies for any confusion caused by the C-D-E-C example I initially suggested—it may not have been the best illustration of the concept. Here’s a refresher on the file structure from our tests:

    dependencies = {
        'a.js': ['b.js', 'c.js'],
        'b.js': [],
        'c.js': ['d.js', 'e.js'],
        'd.js': [],
        'e.js': ['f.js'],
        'f.js': ['g.js', 'c.js'],
        'g.js': ['b.js']
    }
    
    a.js
    ├─ b.js
    └─ c.js
       ├─ d.js
       └─ e.js
          └─ f.js
             ├─ g.js
             │  └─ b.js
             └─ c.js (circular dependency back to c.js)
    

    This tree structure helps illustrate potential dependency chains, such as A-C-E-F is like the A => B => C => D mentioned in the PR description. When discussing depth=1, I find that the outputs might not align with what one would expect, like A-C-D in the example in the description, which would be A-E-F in our test case accordingly. This which should help us better understand the effects of limiting depth. Could we consider revisiting this part to ensure it aligns more clearly with our objectives?

  2. Circular Dependencies
    I generally strive to avoid circular dependencies, too. I found this repository trying to include checks for these circular dependencies in the CI/CD process. Kudos to the contributor for implementing such a valuable feature!

  3. Clarification on the 'depth' Functionality
    Could you possibly provide more concrete examples or use cases for the depth functionality? While your explanation was insightful, additional examples would help solidify my understanding of its practical impact.

  4. Depth Traversal Options
    Concerning your point on depth traversal:

    When it comes to "limiting the depth", there are two ways to go about it. One is to stop traversing at depth and ignore all references originating from that point onward. This is not usually what the user wants.

    I slightly disagree here. In some scenarios, particularly during refactoring, it could be beneficial to understand the breadth of a module's direct influence, which would support limiting traversal as a feature.

  5. Confusion Over Terminology
    These two terms in the description seem to mean the same thing, leading to some confusion. Could you clarify these definitions?

  • the closest input module ("deep dependencies")

  • non-deep modules (i.e. modules that are <=depth steps away from any input module)

  1. Implementation Details
    I've noticed significant changes in the convertTree(depTree, tree, pathCache) method, particularly the removal of pathCache. This alteration could potentially lead to increase execution times significantly. I believe reintroducing pathCache might enhance performance. I'll need some more time to evaluate this change thoroughly.

Thank you once again for your support and understanding as we refine this feature.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a few nodes for the tree so that we can be on the same page with "deep dependencies"

dependencies = {
    'a.js': ['b.js', 'c.js'],
    'b.js': ['h.js'],
    'c.js': ['d.js', 'e.js'],
    'd.js': [],
    'e.js': ['f.js'],
    'f.js': ['g.js', 'c.js'],
    'g.js': ['b.js', 'h.js'],
    'h.js': ['i.js'],
    'i.js': ['j.js'],
    'j.js': ['k.js'],
    'k.js': ['l.js'],
    'l.js': []
}

a.js
├─ b.js
│  └─ h.js
│     └─ i.js
│        └─ j.js
│           └─ k.js
│              └─ l.js
└─ c.js
   ├─ d.js
   └─ e.js
      └─ f.js
         ├─ g.js
         │  ├─ b.js
         │  └─ h.js
         │     └─ i.js
         │        └─ j.js
         │           └─ k.js
         │              └─ l.js
         └─ c.js (circular dependency back to c.js)

For my understanding, with depth=1, we are excluding file b, c. And with depth=2 we are also excluding h, d and e in addition. But here h would appear again in the chain ACEFGH which made the output unclear.
(This also shows that removing the pathCache might be a necessity to implement this feature, depending on what we are trying to achieve.)

});
});
2 changes: 2 additions & 0 deletions test/deep-dependencies/a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import 'b.js';
import 'c.js';
Empty file added test/deep-dependencies/b.js
Empty file.
2 changes: 2 additions & 0 deletions test/deep-dependencies/c.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import 'd.js';
import 'e.js';
Empty file added test/deep-dependencies/d.js
Empty file.
1 change: 1 addition & 0 deletions test/deep-dependencies/e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'f.js';
2 changes: 2 additions & 0 deletions test/deep-dependencies/f.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import 'c.js';
import 'g.js';
1 change: 1 addition & 0 deletions test/deep-dependencies/g.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'b.js';