├── .gitignore ├── README.md ├── package.json ├── src ├── cli.js └── index.js └── test └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webpack Cylic Dependency Checker 2 | 3 | This tool analyzes your Webpack stats.json file to look for cyclic dependencies using "depth first" traversal. It detects cycles between any number of files, as well as files that require themeselves. 4 | 5 | **Why?** Some tools like [Flow](https://flowtype.org/), and some editors, will crash or hang forever when trying to analyze projects that use cyclic dependencies. Also cyclic dependencies are often a symptom of poorly organized code. Detecting them can help clean up a project. See the [examples of cycle](https://github.com/DelvarWorld/webpack-cyclic-dependency-checker#examples-of-cycles) for a demonstration. 6 | 7 | ## Install 8 | 9 | ```sh 10 | npm install --save-dev webpack-cyclic-dependency-checker 11 | ``` 12 | ## Command Line Usage 13 | 14 | First, generate a Webpack stats.json file by passing in the `--json` flag. The full command might look something like: 15 | 16 | ```sh 17 | webpack --json --config webpack.config.js > stats.json 18 | ``` 19 | 20 | Then pass the relative stats.json file path to this tool: 21 | 22 | ```sh 23 | iscyclic stats.json 24 | ``` 25 | 26 | If there's a cycle in your dependencies, the output will resemble: 27 | 28 | ```sh 29 | Detected cycle! 30 | (14) ./src/index.js -> (327) ./src/components/HomePage.js -> (14) ./src/index.js 31 | ``` 32 | 33 | You can see in this example output `./src/index.js` is repeated at the beginning and end. That's showing the full cycle. 34 | 35 | The numbers are the Webpack module IDs. This tool won't show the specific lines of the require / import statements, but it's fairly simple to find those lines in the specified files 36 | 37 | ## Command Line Options 38 | 39 | #### `--include-node-modules` 40 | 41 | By default all dependencies inside `node_modules` are ignored. This tool is mainly designed to detect cycles in your source code. If you want to include searching for cycles in external dependencies, use the `--include-node-modules` flag: 42 | 43 | ```sh 44 | iscyclic stats.json --include-node-modules 45 | ``` 46 | 47 | ## Use in Node.js 48 | 49 | You can require the functions used in this library directly in Node.js. 50 | 51 | 52 | ```js 53 | const cyclicUtils = require( 'cyclicUtils' ); 54 | 55 | const statsJson = require( './path/to/stats.json' ); 56 | const includeNodeModules = false; 57 | 58 | const dependencyGraph = cyclicUtils.getDependencyGraphFromStats( 59 | statsJson, includeNodeModules 60 | ); 61 | 62 | const cycle = cyclicUtils.isCyclic( dependencyGraph ); 63 | ``` 64 | 65 | Check out [cli.js](https://github.com/DelvarWorld/webpack-cyclic-dependency-checker/blob/master/src/cli.js) to see how the output is parsed. 66 | 67 | #### `getDependencyGraphFromStats( json:Object, includeNodeModules:booelan )` 68 | 69 | Generates the dependency graph from Webpack's stats.json file. The output looks something like: 70 | 71 | ```js 72 | { 73 | 1: [ 2 ], 74 | 2: [], 75 | } 76 | ``` 77 | 78 | Where the key is the Webpack module ID, and the array is the IDs of each dependency. 79 | 80 | #### `isCyclic( dependencyGraph:Object )` 81 | 82 | Returns the an array of module IDs in the cycle if a cycle is detected. Otherwise returns `null`. 83 | 84 | ## Examples of Cycles 85 | 86 | #### Cycle Through Multiple Files 87 | 88 | **A.js** 89 | ```js 90 | import B from './B.js'; 91 | ``` 92 | 93 | **B.js** 94 | ```js 95 | import C from './C.js'; 96 | ``` 97 | 98 | **C.js** 99 | ```js 100 | import A from './A.js'; 101 | ``` 102 | 103 | This results in a cycle, from `A` to `B` to `C` with the "back edge" going from `C` to `A`. 104 | 105 | #### Cycle Through Self 106 | 107 | **A.js** 108 | ``` 109 | import { something } from './A'; 110 | export { something: true }; 111 | ``` 112 | 113 | A cycle like this is usually a mistake in the code, but it's perfectly valid ES6 syntax. 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-cyclic-dependency-checker", 3 | "version": "0.0.1", 4 | "description": "Webpack cylic dependency checker. Does a depth-first traversal of webpack's stats.json output", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "mocha test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/DelvarWorld/webpack-cyclic-dependency-checker.git" 12 | }, 13 | "keywords": [ 14 | "webpack", 15 | "cyclic", 16 | "dependency", 17 | "cycle", 18 | "graph" 19 | ], 20 | "author": "Andrew Ray", 21 | "license": "MIT", 22 | "dependencies": { 23 | "command-line-args": "^3.0.0" 24 | }, 25 | "bin": { 26 | "iscyclic": "./src/cli.js" 27 | }, 28 | "devDependencies": { 29 | "chai": "^3.5.0", 30 | "jasmine-node": "^1.14.5", 31 | "mocha": "^2.5.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Needed for this to be executed as a command line utility 3 | 4 | const cyclicUtils = require('./'); 5 | const path = require('path'); 6 | const fs = require('fs'); 7 | const commandLineArgs = require('command-line-args'); 8 | 9 | const usage = ' Usage: iscyclic stats.json [--include-node-modules]'; 10 | const options = commandLineArgs([ 11 | { name: 'include-node-modules', type: Boolean }, 12 | ]); 13 | 14 | const fileName = process.argv[ 2 ]; 15 | if( !fileName ) { 16 | console.error( 'No filename was passed to this script!' ); 17 | console.error( usage ); 18 | process.exit( 1 ); 19 | } 20 | 21 | // Verify the existence of the stats.json file passed in... 22 | const filePath = path.join( process.cwd(), process.argv[ 2 ] ); 23 | 24 | try { 25 | // This command throws if file isn't found 26 | const stats = fs.lstatSync( filePath ); 27 | 28 | // Lol flow control 29 | if( !stats.isFile() ) { 30 | throw new Error(); 31 | } 32 | } catch( e ) { 33 | console.error( 'Input file ' + filePath + ' was not found!' ); 34 | console.error( usage ); 35 | process.exit( 1 ); 36 | } 37 | 38 | const statsJson = require( filePath ); 39 | 40 | const dependencyGraph = cyclicUtils.getDependencyGraphFromStats( 41 | statsJson, options[ 'include-node-modules' ] 42 | ); 43 | 44 | const cycle = cyclicUtils.isCyclic( dependencyGraph ); 45 | if( cycle ) { 46 | 47 | console.log( 'Detected cycle!' ); 48 | console.log( cycle.map( id => { 49 | const mod = statsJson.modules.find( 50 | mod => mod.id.toString() === id.toString() 51 | ); 52 | if( mod ) { 53 | return '(' + id + ') ' + mod.name; 54 | } 55 | }).filter( m => !!m ).join( ' -> ' ) ); 56 | return true; 57 | 58 | } else { 59 | 60 | console.log( 'No cycle detected in', filePath ); 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Recursively search each module's dependencies for cycles 2 | function isCyclicRecursive( modules, key, visited, recStack ) { 3 | 4 | if( !( key in visited ) && modules[ key ] ) { 5 | 6 | // Mark the current depdenncy id as visited and part of recursion stack 7 | visited[ key ] = true; 8 | 9 | // IDs can be numbers (when used in arrays) or strings (when used as 10 | // object keys) so protect against that as best as possible. 11 | recStack.push( key.toString() ); 12 | 13 | const dependencies = modules[ key ]; 14 | 15 | // Recurse for all the dependencies in this module looking for cycles 16 | const found = !!dependencies.find( dependencyId => { 17 | 18 | // Is there a cycle through this module's dependencies? 19 | const isUnvisitedCycle = ( 20 | !( visited[ dependencyId ] ) && 21 | isCyclicRecursive( modules, dependencyId, visited, recStack ) 22 | ); 23 | 24 | // Did a dependnecy cycle? We're done! 25 | if( isUnvisitedCycle ) { 26 | return true; 27 | 28 | // If we found this dependency before in the recursion stack, save 29 | // it to the stack. This will be the final dependency listed in 30 | // the array, and will pop up the call stack to the previous fn 31 | // call, and the above if branch will be hit 32 | } else if( recStack.indexOf( dependencyId.toString() ) > -1 ) { 33 | recStack.push( dependencyId.toString() ); 34 | return true; 35 | } 36 | 37 | }); 38 | 39 | // Return the recursion stack (walk of ids through the graph) if a 40 | // cycle detected 41 | if( found ) { 42 | return found; 43 | } 44 | 45 | } 46 | 47 | recStack.pop(); 48 | return false; 49 | 50 | } 51 | 52 | // Loops over the modules and their dependencies searching for cycles. Returns 53 | // array of IDs if cycle found. This is based on the horrendous code 54 | // http://www.geeksforgeeks.org/archives/18212 which is a "depth first" search 55 | // algorithm. Is all C++ code like this?? 56 | function isCyclic( modules ) { 57 | 58 | // Mark all the vertices as not visited and not part of recursion 59 | // stack 60 | const keys = Object.keys( modules ); 61 | 62 | const visited = {}; 63 | const recStack = []; 64 | 65 | // Call the recursive helper function to detect cycle in different 66 | // DFS trees 67 | for( var i = 0; i < keys.length; i++ ) { 68 | if( isCyclicRecursive( modules, keys[ i ], visited, recStack ) ) { 69 | 70 | // We could get something like [ 0, 1, 2, 3, 1 ], where the cycle 71 | // doesn't include 0, but we started searching at 0. Only ouptput 72 | // the graph starting from the actual cycle, which is just where 73 | // the first and last element of the array are the same 74 | return recStack.slice( 75 | recStack.indexOf( recStack[ recStack.length - 1 ] ) 76 | ); 77 | } 78 | } 79 | 80 | return null; 81 | 82 | } 83 | 84 | // Webpack output paths will contain node_modules and often the tilde string 85 | // if they come from node_modules 86 | function isNodeModulePath( path ) { 87 | return !!( 88 | !path || 89 | path.toString().indexOf( 'node_modules' ) > -1 || 90 | path.toString().indexOf( './~/' ) > -1 91 | ); 92 | } 93 | 94 | // Convert webpack stats.json input, which looks something like: 95 | // { 96 | // modules: [ 97 | // id: 1, 98 | // reasons: [{ name: 2, moduleId: 2 }] 99 | // ] 100 | // } 101 | // into a graph: 102 | // { 103 | // 2: [ 1 ] 104 | // } 105 | // Essentially grouping it by requirer, not by required file 106 | function getDependencyGraphFromStats( stats, includeNodeModules ) { 107 | 108 | // remove all required modules that are node_modules if specified 109 | const modules = includeNodeModules ? 110 | stats.modules : 111 | stats.modules.filter( m => !isNodeModulePath( m.name ) ); 112 | 113 | return modules.reduce( ( memo, modjule ) => { 114 | 115 | return modjule.reasons.reduce( ( memo, reason ) => { 116 | const existing = memo[ reason.moduleId ] || []; 117 | 118 | // ignore parent/caller modules that are in node_modules. This 119 | // likely is impossible because it would mean a node_module 120 | // includes a userland script 121 | if( !includeNodeModules && isNodeModulePath( reason.moduleId ) ) { 122 | return memo; 123 | } 124 | 125 | const newAssign = {}; 126 | newAssign[ reason.moduleId ] = existing.concat( modjule.id ); 127 | 128 | return Object.assign( {}, memo, newAssign ); 129 | 130 | }, memo ); 131 | 132 | }, {} ); 133 | 134 | } 135 | 136 | module.exports = { 137 | isCyclic: isCyclic, 138 | getDependencyGraphFromStats: getDependencyGraphFromStats 139 | }; 140 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const expect = chai.expect; 3 | 4 | const cyclicUtils = require('../src/index.js'); 5 | 6 | // These tests could use some slimming down but they work for now 7 | 8 | describe( 'getDependencyGraphFromStats', () => { 9 | 10 | it('gets dependencies from stats', () => { 11 | 12 | const id1 = '1'; 13 | const id2 = '2'; 14 | 15 | const input = { 16 | modules: [{ 17 | name: id1, 18 | id: id1, 19 | reasons: [{ 20 | name: id2, 21 | moduleId: id2 22 | }] 23 | }, { 24 | name: id2, 25 | id: id2, 26 | reasons: [{ 27 | name: id1, 28 | moduleId: id1 29 | }] 30 | }] 31 | }; 32 | 33 | const results = cyclicUtils.getDependencyGraphFromStats( input ); 34 | 35 | // The result should be the above graph inverted. The above input 36 | // basically says "id1" is in this bundle because "id2" required it 37 | // (the "reason"). We need to invert it to show that id2 requires id1 38 | const expectedResults = {}; 39 | expectedResults[ id1 ] = [ id2 ]; 40 | expectedResults[ id2 ] = [ id1 ]; 41 | 42 | expect( results ).to.deep.equal( expectedResults ); 43 | 44 | }); 45 | 46 | it('ignores node modules by default', () => { 47 | 48 | const id1 = '1'; 49 | const id2 = './~/2'; 50 | const id3 = 'node_modules/2'; 51 | const id4 = '4'; 52 | 53 | const input = { 54 | modules: [{ 55 | name: id1, 56 | id: id1, 57 | reasons: [{ 58 | name: id2, 59 | moduleId: id2 60 | }] 61 | }, { 62 | name: id2, 63 | id: id2, 64 | reasons: [{ 65 | name: id1, 66 | moduleId: id1 67 | }] 68 | }, { 69 | name: id3, 70 | id: id3, 71 | reasons: [{ 72 | name: id4, 73 | moduleId: id4 74 | }] 75 | }, { 76 | name: id4, 77 | id: id4, 78 | reasons: [{ 79 | name: id1, 80 | moduleId: id1 81 | }] 82 | }] 83 | }; 84 | 85 | const results = cyclicUtils.getDependencyGraphFromStats( input ); 86 | 87 | // Modules starting with ./~/ and node_modules (I don't know when/why 88 | // webpack picks one over the other are third party dependencies and 89 | // are ignored by default 90 | const expectedResults = {}; 91 | 92 | // Should only have a top level id1 in here. Listing node_module in 93 | // deps is fine. Won't happen in real world 94 | expectedResults[ id1 ] = [ id4 ]; 95 | 96 | expect( results ).to.deep.equal( expectedResults ); 97 | expect( results ).to.not.include.key( id2 ); 98 | expect( results ).to.not.include.key( id3 ); 99 | 100 | }); 101 | 102 | it('includes node modules with flag', () => { 103 | 104 | const id1 = '1'; 105 | const id2 = './~/2'; 106 | const id3 = 'node_modules/2'; 107 | 108 | const input = { 109 | modules: [{ 110 | name: id1, 111 | id: id1, 112 | reasons: [{ 113 | name: id2, 114 | moduleId: id2 115 | }] 116 | }, { 117 | name: id2, 118 | id: id2, 119 | reasons: [{ 120 | name: id1, 121 | moduleId: id1, 122 | }], 123 | }, { 124 | name: id3, 125 | id: id3, 126 | reasons: [{ 127 | name: id1, 128 | moduleId: id1, 129 | }], 130 | }] 131 | }; 132 | 133 | const results = cyclicUtils.getDependencyGraphFromStats( input, true ); 134 | 135 | // id1 and id2 should go in the graph 136 | const expectedResults = {}; 137 | expectedResults[ id1 ] = [ id2, id3 ]; 138 | expectedResults[ id2 ] = [ id1 ]; 139 | expect( results ).to.deep.equal( expectedResults ); 140 | 141 | // Nothing is in the bundle because of id3, so it shouldn't get added 142 | expect( results ).to.not.include.key( id3 ); 143 | 144 | }); 145 | 146 | }); 147 | 148 | describe( 'isCyclic', () => { 149 | 150 | it( 'detects self cycle', () => { 151 | 152 | const id = '1'; 153 | 154 | const input = {}; 155 | input[ id ] = [ id ]; 156 | 157 | expect( cyclicUtils.isCyclic( input ) ).to.deep.equal( [ id, id ] ); 158 | 159 | }); 160 | 161 | it( 'detects 2 file cycle', () => { 162 | 163 | const id1 = '1'; 164 | const id2 = '2'; 165 | 166 | const input = {}; 167 | input[ id1 ] = [ id2 ]; 168 | input[ id2 ] = [ id1 ]; 169 | 170 | expect( cyclicUtils.isCyclic( input ) ).to.deep.equal( [ id1, id2, id1 ] ); 171 | 172 | }); 173 | 174 | it( 'detects 3 file cycle', () => { 175 | 176 | const id1 = '1'; 177 | const id2 = '2'; 178 | const id3 = '3'; 179 | 180 | const input = {}; 181 | input[ id1 ] = [ id2 ]; 182 | input[ id2 ] = [ id3 ]; 183 | input[ id3 ] = [ id1 ]; 184 | 185 | expect( cyclicUtils.isCyclic( input ) ).to.deep.equal( [ id1, id2, id3, id1 ] ); 186 | 187 | }); 188 | 189 | it( 'detects no cycle in no cycle graph', () => { 190 | 191 | const id1 = '1'; 192 | const id2 = '2'; 193 | const id3 = '3'; 194 | 195 | const input = {}; 196 | input[ id1 ] = [ id2 ]; 197 | input[ id2 ] = [ id3 ]; 198 | input[ id3 ] = []; 199 | 200 | expect( cyclicUtils.isCyclic( input ) ).to.equal( null ); 201 | 202 | }); 203 | 204 | }); 205 | --------------------------------------------------------------------------------