From a3cb39c5ae139464d0cb4f2275c5235c1b4f4970 Mon Sep 17 00:00:00 2001
From: Joel Kemp <mrjoelkemp@gmail.com>
Date: Sun, 17 Jul 2016 13:25:06 -0400
Subject: [PATCH] Breaking: Dependency Tree class

Features:

* Find Dependents
* toJSON
* toList
* Branch regeneration
---
 index.js      | 423 ++++++++++++++++++++++------------
 lib/Config.js | 101 ++++++++
 lib/Module.js |  31 +++
 lib/debug.js  |   2 +
 package.json  |  17 +-
 test/test.js  | 625 +++++++++++---------------------------------------
 6 files changed, 566 insertions(+), 633 deletions(-)
 create mode 100644 lib/Config.js
 create mode 100644 lib/Module.js
 create mode 100644 lib/debug.js

diff --git a/index.js b/index.js
index 98f347b..4a31eaf 100644
--- a/index.js
+++ b/index.js
@@ -2,205 +2,344 @@ var precinct = require('precinct');
 var path = require('path');
 var fs = require('fs');
 var cabinet = require('filing-cabinet');
-var debug = require('debug')('tree');
-
-function Config(options) {
-  this.filename = options.filename;
-  this.directory = options.directory || options.root;
+var fileExists = require('file-exists');
+var glob = require('glob-all');
+var isDirectory = require('is-directory');
+
+var debug = require('./lib/debug');
+var Config = require('./lib/Config');
+var Module = require('./lib/Module');
+
+function DependencyTree(options) {
+  options = options || {};
+
+  /**
+   * Flat cache/map of processed filename -> Module pairs
+   *
+   * @type {Object}
+   */
   this.visited = options.visited || {};
-  this.isListForm = options.isListForm;
-  this.requireConfig = options.config || options.requireConfig;
-  this.webpackConfig = options.webpackConfig;
 
-  this.filter = options.filter;
+  /**
+   * List of absolute filenames to be processed
+   *
+   * @type {Array}
+   */
+  this.files = [];
+
+  /**
+   * Parsed configuration options
+   *
+   * @type {?Config}
+   */
+  this.config = options ? new Config(options) : null;
+}
 
-  if (!this.filename) { throw new Error('filename not given'); }
-  if (!this.directory) { throw new Error('directory not given'); }
-  if (this.filter && typeof this.filter !== 'function') { throw new Error('filter must be a function'); }
+/**
+ * The file extensions that dependency tree can process
+ *
+ * TODO: Possibly move into Config and make configurable
+ *
+ * @static
+ * @type {String[]}
+ */
+DependencyTree.supportedFileExtensions = cabinet.supportedFileExtensions;
 
-  debug('given filename: ' + this.filename);
+/**
+ * Set of directories to ignore by default
+ *
+ * TODO: Possibly move into Config and make configurable
+ *
+ * @static
+ * @type {String[]}
+ */
+DependencyTree.defaultExcludeDirs = [
+  'node_modules',
+  'bower_components',
+  'vendor'
+];
+
+/**
+ * Generate a dependency tree for a given file or directory
+ *
+ * This assumes that resolution options like webpackConfig or requireConfig
+ * have been supplied during class instantiation as part of the config
+ *
+ * @param  {String} filepath - the file or directory about which to generate a dependency tree
+ */
+DependencyTree.prototype.generate = function(filepath) {
+  filepath = path.resolve(filepath);
 
-  this.filename = path.resolve(process.cwd(), this.filename);
+  var isDir = isDirectory.sync(filepath);
 
-  debug('resolved filename: ' + this.filename);
-  debug('visited: ', this.visited);
-}
+  if (!isDir) {
+    if (!this.config.directory) {
+      debug('Tried to process a file that has no associated directory');
+      throw new Error('To generate a tree for a file, you need to supply a directory as configuration');
+    }
 
-Config.prototype.clone = function() {
-  return new Config(this);
+    this.traverse(filepath);
+    return;
+  }
+
+  var files = this._grabAllFilesToProcess(filepath);
+  debug('files to traverse:\n', files);
+
+  files.forEach(this.traverse, this);
+};
+
+DependencyTree.prototype._grabAllFilesToProcess = function(directory) {
+  debug('grabbing all process-able files within ' + directory);
+
+  var excludes = this.constructor.defaultExcludeDirs.concat(this.config.exclude);
+  var exclusions = Config.processExcludes(excludes, directory);
+
+  var exts = this.constructor.supportedFileExtensions;
+
+  var extensions = exts.length > 1 ?
+                   '+(' + exts.join('|') + ')' :
+                   exts[0];
+
+  var globbers = [directory + '/**/*' + extensions];
+
+  globbers = globbers.concat(exclusions.directories.map(function(d) {
+    return '!' + directory + '/' + d + '/**/*';
+  })
+  .concat(exclusions.files.map(function(f) {
+    return '!' + directory + '/**/' + f;
+  })));
+
+  debug('globbers: ' + globbers.join('\n'));
+
+  return glob.sync(globbers);
 };
 
 /**
- * Recursively find all dependencies (avoiding circular) traversing the entire dependency tree
- * and returns a flat list of all unique, visited nodes
- *
- * @param {Object} options
- * @param {String} options.filename - The path of the module whose tree to traverse
- * @param {String} options.directory - The directory containing all JS files
- * @param {String} [options.requireConfig] - The path to a requirejs config
- * @param {String} [options.webpackConfig] - The path to a webpack config
- * @param {Object} [options.visited] - Cache of visited, absolutely pathed files that should not be reprocessed.
- *                             Format is a filename -> tree as list lookup table
- * @param {Boolean} [options.isListForm=false]
- * @return {Object}
+ * Traverses the dependency tree of the given file, creating
+ * modules and registering any parent/child relationships
+ *
+ * @param  {String} filename
  */
-module.exports = function(options) {
-  var config = new Config(options);
+DependencyTree.prototype.traverse = function(filename) {
+  filename = path.resolve(filename);
+  debug('visiting ' + filename);
 
-  if (!fs.existsSync(config.filename)) {
-    debug('file ' + config.filename + ' does not exist');
-    return config.isListForm ? [] : {};
+  if (this.visited[filename]) {
+    debug('already visited ' + filename);
+    return this.visited[filename];
   }
 
-  var results = traverse(config);
-  debug('traversal complete', results);
+  var results = this.getResolvedDependencies(filename);
+  var dependencies = results.dependencies;
 
-  var tree;
-  if (config.isListForm) {
-    debug('list form of results requested');
+  debug('resolved dependencies for ' + filename + ':\n', dependencies);
 
-    tree = removeDups(results);
-    debug('removed dups from the resulting list');
+  if (this.config.filter) {
+    dependencies = dependencies.filter(this.config.filter);
+  }
 
-  } else {
-    debug('object form of results requested');
+  dependencies.forEach(this.traverse, this);
 
-    tree = {};
-    tree[config.filename] = results;
-  }
+  var module = new Module({
+    filename: filename,
+    ast: results.ast
+  });
+
+  module.dependencies = dependencies.map(this.getModule, this);
+  this._registerDependents(module, dependencies);
 
-  debug('final tree', tree);
-  return tree;
+  this.visited[filename] = module;
 };
 
 /**
- * Executes a post-order depth first search on the dependency tree and returns a
- * list of absolute file paths. The order of files in the list will be the
- * proper concatenation order for bundling.
- *
- * In other words, for any file in the list, all of that file's dependencies (direct or indirect) will appear at
- * lower indices in the list. The root (entry point) file will therefore appear last.
- *
- * The list will not contain duplicates.
+ * Returns the resolved list of dependencies for the given filename
  *
- * Params are those of module.exports
+ * @param  {String} filename
+ * @return {Object[]} result
+ * @return {String[]} result.dependencies - List of dependency paths extracted from filename
+ * @return {Object} result.ast - AST of the given filename
  */
-module.exports.toList = function(options) {
-  options.isListForm = true;
+DependencyTree.prototype.getResolvedDependencies = function(filename) {
+  var results = this.findDependencies(filename);
+
+  debug('raw dependencies for ' + filename + ':\n', results.dependencies);
+
+  var dependencies = results.dependencies.map(function(dep) {
+    var resolvedDep = cabinet({
+      partial: dep,
+      filename: filename,
+      directory: this.config.directory,
+      config: this.config.requireConfig,
+      webpackConfig: this.config.webpackConfig
+    });
+
+    debug('cabinet result ' + resolvedDep);
+
+    return resolvedDep;
+  }.bind(this))
+  .filter(function(dep) {
+    var exists = fileExists(dep);
+
+    if (!exists) {
+      debug('filtering non-existent: ' + dep);
+    }
+
+    return exists;
+  });
 
-  return module.exports(options);
+  return {
+    dependencies: dependencies,
+    ast: results.ast
+  };
 };
 
 /**
  * Returns the list of dependencies for the given filename
  *
- * Protected for testing
- *
  * @param  {String} filename
- * @return {String[]}
+ * @return {Object}
  */
-module.exports._getDependencies = function(filename) {
+DependencyTree.prototype.findDependencies = function(filename) {
   try {
-    return precinct.paperwork(filename, {
-      includeCore: false
-    });
+    return {
+      dependencies: precinct.paperwork(filename, {
+        includeCore: false
+      }),
+      ast: precinct.ast
+    };
 
   } catch (e) {
     debug('error getting dependencies: ' + e.message);
     debug(e.stack);
-    return [];
+    return {
+      dependencies: [],
+      ast: null
+    };
   }
 };
 
 /**
- * @param  {Config} config
- * @return {Object|String[]}
+ * Returns the module object associated with the given filename
+ *
+ * @param  {String} filename
+ * @return {?Module}
  */
-function traverse(config) {
-  var subTree = config.isListForm ? [] : {};
-
-  debug('traversing ' + config.filename);
-
-  if (config.visited[config.filename]) {
-    debug('already visited ' + config.filename);
-    return config.visited[config.filename];
+DependencyTree.prototype.getModule = function(filename) {
+  if (typeof filename !== 'string') {
+    throw new Error('filename must be a string');
   }
 
-  var dependencies = module.exports._getDependencies(config.filename);
+  filename = path.resolve(filename);
 
-  debug('extracted ' + dependencies.length + ' dependencies: ', dependencies);
-
-  dependencies = dependencies.map(function(dep) {
-    var result = cabinet({
-      partial: dep,
-      filename: config.filename,
-      directory: config.directory,
-      config: config.requireConfig,
-      webpackConfig: config.webpackConfig
-    });
-
-    debug('cabinet result ' + result);
-
-    return result;
-  })
-  .filter(function(dep) {
-    var exists = fs.existsSync(dep);
+  return this.visited[filename];
+};
 
-    if (!exists) {
-      debug('filtering non-existent: ' + dep);
+/**
+ * Registers the given module as a dependent of each of the dependencies
+ *
+ * This assumes that each of the dependencies have been visited/traversed
+ *
+ * @private
+ * @param  {Module} module
+ * @param  {String[]} dependencies
+ */
+DependencyTree.prototype._registerDependents = function(module, dependencies) {
+  dependencies.forEach(function(dep) {
+    if (!this.visited[dep]) {
+      debug('error: found an unvisited dependency');
+      throw new Error('found an unvisited dependency');
     }
 
-    return exists;
-  });
+    this.getModule(dep).dependents.push(module);
+  }, this);
+};
 
-  // Prevents cycles by eagerly marking the current file as read
-  // so that any dependent dependencies exit
-  config.visited[config.filename] = config.isListForm ? [] : {};
+/**
+ * Returns a nested object form of the dependency tree
+ *
+ * @example
+ *   {
+ *     'path/to/foo': {
+ *       'path/to/bar': {},
+ *       'path/to/baz': {
+ *         'path/to/car': {}
+ *       }
+ *     }
+ *   }
+ * @param  {Object} options
+ * @return {Object}
+ */
+DependencyTree.prototype.toJSON = function(options) {
+  var json = {};
 
-  if (config.filter) {
-    dependencies = dependencies.filter(config.filter);
-  }
+  // TODO: implement
 
-  dependencies.forEach(function(d) {
-    var localConfig = config.clone();
-    localConfig.filename = d;
+  return json;
+};
 
-    if (localConfig.isListForm) {
-      subTree = subTree.concat(traverse(localConfig));
-    } else {
-      subTree[d] = traverse(localConfig);
-    }
-  });
+/**
+ * Executes a post-order depth first search on the dependency tree and returns a
+ * list of absolute file paths. The order of files in the list will be the
+ * proper concatenation order for bundling.
+ *
+ * In other words, for any file in the list, all of that file's dependencies (direct or indirect) will appear at
+ * lower indices in the list. The root (entry point) file will therefore appear last.
+ *
+ * The list will not contain duplicates.
+ *
+ * Params are those of module.exports
+ */
+DependencyTree.prototype.toList = function(options) {
+  // TODO: implement
+};
 
-  if (config.isListForm) {
-    // Prevents redundancy about each memoized step
-    subTree = removeDups(subTree);
-    subTree.push(config.filename);
-    config.visited[config.filename] = config.visited[config.filename].concat(subTree);
+/**
+ * Deletes a file from the dependency tree adjusting any parent/child relationships appropriately
+ *
+ * This is useful if a file has been removed from the filesystem
+ *
+ * @param  {String} filename
+ */
+DependencyTree.prototype.delete = function(filename) {
+  var module = this.getModule(filename);
 
-  } else {
-    config.visited[config.filename] = subTree;
-  }
+  // TODO: Remove the module from the dependents list of module.dependencies
 
-  return subTree;
-}
+  delete this.visited[filename];
+};
 
 /**
- * Returns a list of unique items from the array
+ * Regenerates the dependency tree for the given file
  *
- * @param  {String[]} list
- * @return {String[]}
+ * This is useful if a file has been modified
+ *
+ * @param  {String} filename
  */
-function removeDups(list) {
-  var cache = {};
-  var unique = [];
-
-  list.forEach(function(item) {
-    if (!cache[item]) {
-      unique.push(item);
-      cache[item] = true;
-    }
-  });
+DependencyTree.prototype.regenerate = function(filename) {
+  // TODO: Could this be this.traverse(filename)?
+};
 
-  return unique;
-}
+// TODO: Do we want to return String[] or Module[]
+DependencyTree.prototype.getDependencies = function(filename) {
+  filename = path.resolve(filename);
+
+  return this.visited[filename].dependencies;
+};
+
+// TODO: Do we want to return String[] or Module[]
+DependencyTree.prototype.getDependents = function(filename) {
+  filename = path.resolve(filename);
+
+  return this.visited[filename].dependents;
+};
+
+DependencyTree.prototype.getRoots = function() {
+  // TODO: Get all nodes that have no dependents
+};
+
+DependencyTree.prototype.getPathToFile = function(filename) {
+  // TODO: return list of string paths (filename -> filename maybe)
+  // from a root to the module associated with the given filename
+};
+
+module.exports = DependencyTree;
diff --git a/lib/Config.js b/lib/Config.js
new file mode 100644
index 0000000..8e55074
--- /dev/null
+++ b/lib/Config.js
@@ -0,0 +1,101 @@
+var path = require('path');
+var debug = require('./debug');
+var fs = require('fs');
+
+function Config(options) {
+  this.directory = options.directory || options.root;
+
+  this.isListForm = options.isListForm;
+  this.requireConfig = options.config || options.requireConfig;
+  this.webpackConfig = options.webpackConfig;
+  this.exclude = options.exclude || [];
+
+  this.filter = options.filter;
+
+  if (this.filter && typeof this.filter !== 'function') {
+    throw new Error('filter must be a function');
+  }
+}
+
+Config.prototype.clone = function() {
+  return new Config(this);
+};
+
+
+/**
+ * Separates out the excluded directories and files
+ *
+ * @todo move out to its own module
+ *
+ * @static
+ * @param  {String[]} excludes - list of glob patterns for exclusion
+ * @param  {String} directory - Used for resolving the exclusion to the filesystem
+ *
+ * @return {Object} results
+ * @return {String} results.directoriesPattern - regex representing the directories
+ * @return {String[]} results.directories
+ * @return {String} results.filesPattern - regex representing the files
+ * @return {String[]} results.files
+ */
+Config.processExcludes = function(excludes, directory) {
+  var results = {
+    directories: [],
+    directoriesPattern: '',
+    files: [],
+    filesPattern: ''
+  };
+
+  if (!excludes) { return results; }
+
+  var dirs = [];
+  var files = [];
+
+  excludes.forEach(function(exclude) {
+    // Globbing breaks with excludes like foo/bar
+    if (stripTrailingSlash(exclude).indexOf('/') !== -1) {
+      debug('excluding from processing: ' + exclude);
+      return;
+    }
+
+    try {
+      var resolved = path.resolve(directory, exclude);
+      var stats = fs.lstatSync(resolved);
+
+      if (stats.isDirectory()) {
+        dirs.push(stripTrailingSlash(exclude));
+
+      } else if (stats.isFile()) {
+        exclude = path.basename(exclude);
+        files.push(exclude);
+      }
+    } catch (e) {
+      // Ignore files that don't exist
+    }
+  }, this);
+
+  if (dirs.length) {
+    results.directoriesPattern = new RegExp(dirs.join('|'));
+    results.directories = dirs;
+  }
+
+  if (files.length) {
+    results.filesPattern = new RegExp(files.join('|'));
+    results.files = files;
+  }
+
+  return results;
+};
+
+/**
+ * @param  {String} str
+ * @return {String}
+ */
+function stripTrailingSlash(str) {
+  if (str[str.length - 1] === '/') {
+    return str.slice(0, -1);
+  }
+
+  return str;
+};
+
+module.exports = Config;
diff --git a/lib/Module.js b/lib/Module.js
new file mode 100644
index 0000000..9fdc973
--- /dev/null
+++ b/lib/Module.js
@@ -0,0 +1,31 @@
+function Module(options) {
+  options = options || {};
+
+  /**
+   * List of module references that this module depends on
+   *
+   * @type {Module[]}
+   */
+  this.dependencies = options.dependencies || [];
+
+  /**
+   * List of module references that depend on this module
+   *
+   * @type {Module[]}
+   */
+  this.dependents = options.dependents || [];
+
+  /**
+   * The absolute filename corresponding to this module
+   * @type {String}
+   */
+  this.filename = options.filename || '';
+
+  /**
+   * The parsed AST corresponding to this module
+   * @type {[type]}
+   */
+  this.ast = options.ast || null;
+}
+
+module.exports = Module;
diff --git a/lib/debug.js b/lib/debug.js
new file mode 100644
index 0000000..ac70f4f
--- /dev/null
+++ b/lib/debug.js
@@ -0,0 +1,2 @@
+// Only to ensure the same namespace
+module.exports = require('debug')('tree');
diff --git a/package.json b/package.json
index 7c7ab0c..80159fc 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,10 @@
   "description": "Get the dependency tree of a module (as a list)",
   "main": "index.js",
   "scripts": {
-    "test": "jscs index.js test/test.js && ./node_modules/.bin/mocha --compilers js:babel/register test/test.js"
+    "test": "npm run lint && npm run tests",
+    "lint": "jscs index.js test/test.js",
+    "tests": "mocha --compilers js:babel/register test/test.js",
+    "watch-tests": "DEBUG=*,-mocha:*,-parse:*,-babel mocha --watch --compilers js:babel/register test/test.js"
   },
   "bin": {
     "dependency-tree": "bin/cli.js"
@@ -31,15 +34,19 @@
   "homepage": "https://github.com/mrjoelkemp/node-dependency-tree",
   "dependencies": {
     "commander": "^2.6.0",
-    "debug": "~2.2.0",
-    "filing-cabinet": "~1.2.11",
-    "precinct": "^3.0.0"
+    "debug": "^2.2.0",
+    "file-exists": "~1.0.0",
+    "filing-cabinet": "^1.3.0",
+    "glob-all": "~3.0.3",
+    "is-directory": "~0.3.1",
+    "precinct": "^3.1.1"
   },
   "devDependencies": {
+    "array-includes": "~3.0.2",
     "babel": "~5.8.38",
     "jscs": "~2.11.0",
     "jscs-preset-mrjoelkemp": "~1.0.0",
-    "mocha": "~2.0.1",
+    "mocha": "~2.5.3",
     "mock-fs": "~3.9.0",
     "rewire": "~2.5.2",
     "sinon": "~1.12.2"
diff --git a/test/test.js b/test/test.js
index cd4aff6..96e9821 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1,564 +1,217 @@
 import assert from 'assert';
-import sinon from 'sinon';
 import mockfs from 'mock-fs';
-import path from 'path';
-
-import rewire from 'rewire';
-const dependencyTree = rewire('../');
-
-describe('dependencyTree', function() {
-  function testTreesForFormat(format, ext = '.js') {
-    it('returns an object form of the dependency tree for a file', function() {
-      const root = `${__dirname}/example/${format}`;
-      const filename = `${root}/a${ext}`;
-
-      const tree = dependencyTree({filename, root});
-
-      assert(tree instanceof Object);
-
-      const aSubTree = tree[filename];
-
-      assert.ok(aSubTree instanceof Object);
-      const filesInSubTree = Object.keys(aSubTree);
+import includes from 'array-includes';
+import sinon from 'sinon';
 
-      assert.equal(filesInSubTree.length, 2);
-    });
-  }
-
-  function mockStylus() {
-    mockfs({
-      [__dirname + '/example/stylus']: {
-        'a.styl': `
-          @import "b"
-          @require "c.styl"
-        `,
-        'b.styl': '@import "c"',
-        'c.styl': ''
-      }
-    });
-  }
-
-  function mockSass() {
-    mockfs({
-      [__dirname + '/example/sass']: {
-        'a.scss': `
-          @import "_b";
-          @import "_c.scss";
-        `,
-        '_b.scss': 'body { color: blue; }',
-        '_c.scss': 'body { color: pink; }'
-      }
-    });
-  }
-
-  function mockes6() {
-    mockfs({
-      [__dirname + '/example/es6']: {
-        'a.js': `
-          import b from './b';
-          import c from './c';
-        `,
-        'b.js': 'export default function() {};',
-        'c.js': 'export default function() {};',
-        'jsx.js': `import c from './c';\n export default <jsx />;`,
-        'es7.js': `import c from './c';\n export default async function foo() {};`
-      }
-    });
-  }
+import DependencyTree from '../';
+import Module from '../lib/Module';
 
+describe('DependencyTree', function() {
   afterEach(function() {
     mockfs.restore();
   });
 
-  it('returns an empty object for a non-existent filename', function() {
-    mockfs({
-      imaginary: {}
+  it('does not throw when initialized with no arguments', function() {
+    assert.doesNotThrow(() => {
+      new DependencyTree();
     });
-
-    const root = __dirname + '/imaginary';
-    const filename = root + '/notafile.js';
-    const tree = dependencyTree({filename, root});
-
-    assert(tree instanceof Object);
-    assert(!Object.keys(tree).length);
   });
 
-  it('handles nested tree structures', function() {
-    mockfs({
-      [__dirname + '/extended']: {
-        'a.js': `var b = require('./b');
-                 var c = require('./c');`,
-        'b.js': `var d = require('./d');
-                 var e = require('./e');`,
-        'c.js': `var f = require('./f');
-                 var g = require('./g');`,
-        'd.js': '',
-        'e.js': '',
-        'f.js': '',
-        'g.js': ''
-      }
-    });
-
-    const directory = __dirname + '/extended';
-    const filename = directory + '/a.js';
-
-    const tree = dependencyTree({filename, directory});
-    assert(tree[filename] instanceof Object);
-
-    // b and c
-    const subTree = tree[filename];
-    assert.equal(Object.keys(subTree).length, 2);
-
-    const bTree = subTree[directory + '/b.js'];
-    const cTree = subTree[directory + '/c.js'];
-    // d and e
-    assert.equal(Object.keys(bTree).length, 2);
-    // f ang g
-    assert.equal(Object.keys(cTree).length, 2);
-  });
-
-  it('does not include files that are not real (#13)', function() {
-    mockfs({
-      [__dirname + '/onlyRealDeps']: {
-        'a.js': 'var notReal = require("./notReal");'
-      }
-    });
-
-    const directory = __dirname + '/onlyRealDeps';
-    const filename = directory + '/a.js';
-
-    const tree = dependencyTree({filename, directory});
-    const subTree = tree[filename];
-
-    assert.ok(!Object.keys(subTree).some(dep => dep.indexOf('notReal') !== -1));
-  });
-
-  it('does not choke on cyclic dependencies', function() {
-    mockfs({
-      [__dirname + '/cyclic']: {
-        'a.js': 'var b = require("./b");',
-        'b.js': 'var a = require("./a");'
-      }
-    });
-
-    const directory = __dirname + '/cyclic';
-    const filename = directory + '/a.js';
-
-    const spy = sinon.spy(dependencyTree, '_getDependencies');
-
-    const tree = dependencyTree({filename, directory});
-
-    assert(spy.callCount === 2);
-    assert(Object.keys(tree[filename]).length);
-
-    dependencyTree._getDependencies.restore();
-  });
-
-  it('excludes Nodejs core modules by default', function() {
-    const directory = __dirname + '/example/commonjs';
-    const filename = directory + '/b.js';
-
-    const tree = dependencyTree({filename, directory});
-    assert(Object.keys(tree[filename]).length === 0);
-    assert(Object.keys(tree)[0].indexOf('b.js') !== -1);
-  });
-
-  it('traverses installed 3rd party node modules', function() {
-    const directory = __dirname + '/example/onlyRealDeps';
-    const filename = directory + '/a.js';
-
-    const tree = dependencyTree({filename, directory});
-    const subTree = tree[filename];
-
-    assert(Object.keys(subTree).some(dep => dep === require.resolve('debug')));
+  it('throws if given a filter value that is not a function', function() {
+    assert.throws(() => {
+      new DependencyTree({
+        filter: 'foo',
+      });
+    }, Error, 'filter must be a function');
   });
 
-  it('returns a list of absolutely pathed files', function() {
-    const directory = __dirname + '/example/commonjs';
-    const filename = directory + '/b.js';
+  describe('#traverse', function() {
+    before(function() {
+      this._directory =  __dirname + '/example/es6';
 
-    const tree = dependencyTree({filename, directory});
+      mockfs({
+        [this._directory]: {
+          'a.js': `
+            import b from './b';
+            import c from './c';
+          `,
+          'b.js': 'import d from "./d"; export default 1;',
+          'c.js': 'import d from "./d"; export default 1;',
+          'd.js': 'import e from "./subdir/e"; export default 1;',
+          subdir: {
+            'e.js': 'export default 2;'
+          }
+        }
+      });
 
-    for (let node in tree.nodes) {
-      assert(node.indexOf(process.cwd()) !== -1);
-    }
-  });
+      this._dependencyTree = new DependencyTree({
+        directory: this._directory
+      });
 
-  it('excludes duplicate modules from the tree', function() {
-    mockfs({
-      root: {
-        // More than one module includes c
-        'a.js': `import b from "b";
-                 import c from "c";`,
-        'b.js': 'import c from "c";',
-        'c.js': 'export default 1;'
-      }
+      this._dependencyTree.traverse(`${this._directory}/a.js`);
     });
 
-    const tree = dependencyTree.toList({
-      filename: 'root/a.js',
-      directory: 'root'
+    it('registers all visited modules', function() {
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/a.js`) instanceof Module);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/b.js`) instanceof Module);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/c.js`) instanceof Module);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/d.js`) instanceof Module);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/subdir/e.js`) instanceof Module);
     });
 
-    assert(tree.length === 3);
-  });
-
-  describe('throws', function() {
-    beforeEach(function() {
-      this._directory = __dirname + '/example/commonjs';
-      this._revert = dependencyTree.__set__('traverse', () => []);
+    it('provides the parsed ASTs for every visited module', function() {
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/a.js`).ast);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/b.js`).ast);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/c.js`).ast);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/d.js`).ast);
+      assert.ok(this._dependencyTree.getModule(`${this._directory}/subdir/e.js`).ast);
     });
 
-    afterEach(function() {
-      this._revert();
-    });
+    it('registers the dependencies for the file', function() {
+      const {dependencies} = this._dependencyTree.getModule(`${this._directory}/a.js`);
 
-    it('throws if the filename is missing', function() {
-      assert.throws(function() {
-        dependencyTree({
-          filename: undefined,
-          directory: this._directory
-        });
-      });
+      assert.ok(includes(dependencies, this._dependencyTree.getModule(`${this._directory}/b.js`)));
+      assert.ok(includes(dependencies, this._dependencyTree.getModule(`${this._directory}/c.js`)));
     });
 
-    it('throws if the root is missing', function() {
-      assert.throws(function() {
-        dependencyTree({filename});
-      });
+    it('registers dependencies within subdirectories', function() {
+      const {dependencies} = this._dependencyTree.getModule(`${this._directory}/d.js`);
+      assert.ok(includes(dependencies, this._dependencyTree.getModule(`${this._directory}/subdir/e.js`)));
     });
 
-    it('throws if a supplied filter is not a function', function() {
-      assert.throws(function() {
-        const directory = __dirname + '/example/onlyRealDeps';
-        const filename = directory + '/a.js';
+    it('registers the dependents for the dependencies of the file', function() {
+      const {dependents} = this._dependencyTree.getModule(`${this._directory}/b.js`);
 
-        const tree = dependencyTree({
-          filename,
-          directory,
-          filter: 'foobar'
-        });
-      });
+      assert.ok(includes(dependents, this._dependencyTree.getModule(`${this._directory}/a.js`)));
     });
 
-    it('does not throw on the legacy `root` option', function() {
-      assert.doesNotThrow(function() {
-        const directory = __dirname + '/example/onlyRealDeps';
-        const filename = directory + '/a.js';
+    it('handles dependents within the parent directory', function() {
+      const {dependents} = this._dependencyTree.getModule(`${this._directory}/subdir/e.js`);
 
-        const tree = dependencyTree({
-          filename,
-          root: directory
-        });
-      });
-    });
-  });
-
-  describe('on file error', function() {
-    beforeEach(function() {
-      this._directory = __dirname + '/example/commonjs';
+      assert.ok(includes(dependents, this._dependencyTree.getModule(`${this._directory}/d.js`)));
     });
 
-    it('does not throw', function() {
-      assert.doesNotThrow(() => {
-        dependencyTree({
-          filename: 'foo',
-          directory: this._directory
-        });
-      });
-    });
+    it('can handle multiple dependents for a module', function() {
+      const {dependents} = this._dependencyTree.getModule(`${this._directory}/d.js`);
 
-    it('returns no dependencies', function() {
-      const tree = dependencyTree({filename: 'foo', directory: this._directory});
-      assert(!tree.length);
+      assert.ok(includes(dependents, this._dependencyTree.getModule(`${this._directory}/b.js`)));
+      assert.ok(includes(dependents, this._dependencyTree.getModule(`${this._directory}/c.js`)));
     });
   });
 
-  describe('memoization (#2)', function() {
+  describe('#generate', function() {
     beforeEach(function() {
-      this._spy = sinon.spy(dependencyTree, '_getDependencies');
-    });
-
-    afterEach(function() {
-      dependencyTree._getDependencies.restore();
-    });
-
-    it('accepts a cache object for memoization (#2)', function() {
-      const filename = __dirname + '/example/amd/a.js';
-      const directory = __dirname + '/example/amd';
-      const cache = {};
-
-      cache[__dirname + '/example/amd/b.js'] = [
-        __dirname + '/example/amd/b.js',
-        __dirname + '/example/amd/c.js'
-      ];
-
-      const tree = dependencyTree({
-        filename,
-        directory,
-        visited: cache
-      });
-
-      assert.equal(Object.keys(tree[filename]).length, 2);
-      assert(this._spy.neverCalledWith(__dirname + '/example/amd/b.js'));
-    });
-
-    it('returns the precomputed list of a cached entry point', function() {
-      const filename = __dirname + '/example/amd/a.js';
-      const directory = __dirname + '/example/amd';
+      this._directory =  __dirname + '/example/es6';
 
-      const cache = {
-        // Shouldn't process the first file's tree
-        [filename]: []
-      };
-
-      const tree = dependencyTree({
-        filename,
-        directory,
-        visited: cache
+      mockfs({
+        [this._directory]: {
+          'a.js': `
+            import b from './b';
+            import c from './c';
+          `,
+          'b.js': 'import d from "./d"; export default 1;',
+          'c.js': 'import d from "./d"; export default 1;',
+          'd.js': 'import e from "./subdir/e"; export default 1;',
+          'e.js': 'import foo from "foo";',
+          subdir: {
+            'e.js': 'export default 2;'
+          },
+          'node_modules': {
+            foo: {
+              'index.js': 'export default 1;',
+              'package.json': `{
+                "main": "index.js"
+              }`
+            }
+          }
+        }
       });
-
-      assert(!tree.length);
-    });
-  });
-
-  describe('module formats', function() {
-    describe('amd', function() {
-      testTreesForFormat('amd');
-    });
-
-    describe('commonjs', function() {
-      testTreesForFormat('commonjs');
     });
 
-    describe('es6', function() {
+    describe('when given a file', function() {
       beforeEach(function() {
-        this._directory = __dirname + '/example/es6';
-        mockes6();
+        this._file = `${this._directory}/a.js`;
       });
 
-      testTreesForFormat('es6');
+      describe('with no specified directory, ', function() {
+        it('throws an error', function() {
+          this._dependencyTree = new DependencyTree();
 
-      it('resolves files that have jsx', function() {
-        const filename = `${this._directory}/jsx.js`;
-        const {[filename]: tree} = dependencyTree({
-          filename,
-          directory: this._directory
+          assert.throws(() => {
+            this._dependencyTree.generate(this._file);
+          }, Error, 'To generate a tree for a file, you need to supply a directory as configuration');
         });
-
-        assert.ok(tree[`${this._directory}/c.js`]);
       });
 
-      it('resolves files that have es7', function() {
-        const filename = `${this._directory}/es7.js`;
-        const {[filename]: tree} = dependencyTree({
-          filename,
-          directory: this._directory
-        });
+      describe('within a specified directory', function() {
+        it('generates the tree for that file', function() {
+          this._dependencyTree = new DependencyTree({
+            directory: this._directory
+          });
 
-        assert.ok(tree[`${this._directory}/c.js`]);
-      });
-    });
+          const stub = sinon.stub(this._dependencyTree, 'traverse');
 
-    describe('sass', function() {
-      beforeEach(function() {
-        mockSass();
-      });
+          this._dependencyTree.generate(this._file);
+          assert.ok(stub.called);
 
-      testTreesForFormat('sass', '.scss');
-    });
-
-    describe('stylus', function() {
-      beforeEach(function() {
-        mockStylus();
-      });
-
-      testTreesForFormat('stylus', '.styl');
-    });
-  });
-
-  describe('toList', function() {
-    function testToList(format, ext = '.js') {
-      it('returns a post-order list form of the dependency tree', function() {
-        const directory = __dirname + '/example/' + format;
-        const filename = directory + '/a' + ext;
-
-        const list = dependencyTree.toList({
-          filename,
-          directory
+          stub.restore();
         });
-
-        assert(list instanceof Array);
-        assert(list.length);
       });
-    }
-
-    it('returns an empty list on a non-existent filename', function() {
-      mockfs({
-        imaginary: {}
-      });
-
-      const directory = __dirname + '/imaginary';
-      const filename = directory + '/notafile.js';
-
-      const list = dependencyTree.toList({
-        filename,
-        directory
-      });
-
-      assert(list instanceof Array);
-      assert(!list.length);
     });
 
-    it('orders the visited files by last visited', function() {
-      const directory = __dirname + '/example/amd';
-      const filename = directory + '/a.js';
-      const list = dependencyTree.toList({
-        filename,
-        directory
-      });
-
-      assert(list.length === 3);
-      assert(list[0] === directory + '/c.js');
-      assert(list[1] === directory + '/b.js');
-      assert(list[list.length - 1] === filename);
-    });
+    describe('when given a directory', function() {
+      beforeEach(function() {
+        this._dependencyTree = new DependencyTree({
+          directory: this._directory
+        });
 
-    describe('module formats', function() {
-      describe('amd', function() {
-        testToList('amd');
-      });
+        this._traverse = sinon.stub(this._dependencyTree, 'traverse');
 
-      describe('commonjs', function() {
-        testToList('commonjs');
+        this._dependencyTree.generate(this._directory);
       });
 
-      describe('es6', function() {
-        beforeEach(function() {
-          mockes6();
-        });
-
-        testToList('es6');
+      afterEach(function() {
+        this._traverse.restore();
       });
 
-      describe('sass', function() {
-        beforeEach(function() {
-          mockSass();
-        });
-
-        testToList('sass', '.scss');
+      it('traverses all non-excluded files in the directory', function() {
+        assert.ok(this._traverse.calledWith(`${this._directory}/a.js`));
+        assert.ok(this._traverse.calledWith(`${this._directory}/b.js`));
+        assert.ok(this._traverse.calledWith(`${this._directory}/c.js`));
+        assert.ok(this._traverse.calledWith(`${this._directory}/d.js`));
+        assert.ok(this._traverse.calledWith(`${this._directory}/e.js`));
+        assert.ok(this._traverse.calledWith(`${this._directory}/subdir/e.js`));
       });
 
-      describe('stylus', function() {
-        beforeEach(function() {
-          mockStylus();
-        });
-
-        testToList('stylus', '.styl');
+      it('does not traverse excluded files', function() {
+        assert.ok(!this._traverse.calledWith(`${this._directory}/node_modules/foo/index.js`));
       });
     });
-  });
 
-  describe('webpack', function() {
-    beforeEach(function() {
-      // Note: not mocking because webpack's resolver needs a real project with dependencies;
-      // otherwise, we'd have to mock a ton of files.
-      this._root = path.join(__dirname, '../');
-      this._webpackConfig = this._root + '/webpack.config.js';
-
-      this._testResolution = name => {
-        const results = dependencyTree.toList({
-          filename: `${__dirname}/example/webpack/${name}.js`,
-          directory: this._root,
-          webpackConfig: this._webpackConfig
+    describe('when a module depends on a file in node_modules', function() {
+      it('still includes the node_modules file in the generated tree', function() {
+        this._dependencyTree = new DependencyTree({
+          directory: this._directory
         });
 
-        assert.ok(results.some(filename => filename.indexOf('node_modules/filing-cabinet') !== -1));
-      };
-    });
-
-    it('resolves aliased modules', function() {
-      this._testResolution('aliased');
-    });
-
-    it('resolves unaliased modules', function() {
-      this._testResolution('unaliased');
-    });
-  });
-
-  describe('requirejs', function() {
-    beforeEach(function() {
-      mockfs({
-        root: {
-          'lodizzle.js': 'define({})',
-          'require.config.js': `
-            requirejs.config({
-              baseUrl: './',
-              paths: {
-                F: './lodizzle.js'
-              }
-            });
-          `,
-          'a.js': `
-            define([
-              'F'
-            ], function(F) {
-
-            });
-          `,
-          'b.js': `
-            define([
-              './lodizzle'
-            ], function(F) {
-
-            });
-          `
-        }
+        this._dependencyTree.generate(this._directory);
+        assert.ok(this._dependencyTree.getModule(`${this._directory}/node_modules/foo/index.js`));
       });
     });
 
-    it('resolves aliased modules', function() {
-      const tree = dependencyTree({
-        filename: 'root/a.js',
-        directory: 'root',
-        config: 'root/require.config.js'
-      });
-
-      const filename = path.resolve(process.cwd(), 'root/a.js');
-      const aliasedFile = path.resolve(process.cwd(), 'root/lodizzle.js');
-      assert.ok('root/lodizzle.js' in tree[filename]);
-    });
-
-    it('resolves non-aliased paths', function() {
-      const tree = dependencyTree({
-        filename: 'root/b.js',
-        directory: 'root',
-        config: 'root/require.config.js'
-      });
-
-      const filename = path.resolve(process.cwd(), 'root/b.js');
-      const aliasedFile = path.resolve(process.cwd(), 'root/lodizzle.js');
-      assert.ok('root/lodizzle.js' in tree[filename]);
-    });
-  });
+    describe('when given additional excludes', function() {
+      it.skip('does not traverse files in those directories', function() {
+        this._dependencyTree = new DependencyTree({
+          directory: this._directory,
+          // exclude: [
+          //   'subdir/**/*.js'
+          // ]
+        });
 
-  describe('when a filter function is supplied', function() {
-    it('uses the filter to determine if a file should be included in the results', function() {
-      const directory = __dirname + '/example/onlyRealDeps';
-      const filename = directory + '/a.js';
+        this._dependencyTree.generate(this._directory);
 
-      const tree = dependencyTree({
-        filename,
-        directory,
-        // Skip all 3rd party deps
-        filter: (path) => path.indexOf('node_modules') === -1
+        assert.equal(this._dependencyTree.getModule(`${this._directory}/subdir/e.js`), undefined);
       });
-
-      const subTree = tree[filename];
-      assert.ok(Object.keys(tree).length);
-
-      const has3rdPartyDep = Object.keys(subTree).some(dep => dep === require.resolve('debug'));
-      assert.ok(!has3rdPartyDep);
     });
   });
 });