Repository: ember-cli/ember-cli
Pull request #7729 (2018-04-03 02:05:44)

package-info-cache (fast package caching)
This is now at the point where all previous ember-cli tests pass (I will shortly add tests for package-info-cache). I would like @stefanpenner and @rwjblue to take a look and suggest improvements before the final PR with new tests goes in. Here's the feature description:

Doing profiling of build time processing for a large project at LinkedIn found that the addon discovery process was taking over 50 seconds just to determine all where all the addon packages were on disk and read and parse their package.json files. I found that much of this was due to repeated validation of the path to the addon package.json and index.js files (fs.realpathSync turns out to be fairly expensive when done a lot, and the project was calling it something like 80K times). The implementation was already re-reading the package information and then throwing it away many times.

This PR creates a new PackageInfoCache class, with a singleton instance added to either CLI (if the build is being done through the CLI or an individual project (if the build is done on a single project but not through the CLI). If a build, like ours here at LinkedIn, includes multiple projects, the fact the instance is in CLI means they all take advantage of the same cache (I'll call it PIC from here out) and don't need to rediscover any packages.

When a Project (ember-cli/lib/models/project) is constructed, a reference to a local or CLI version of PIC is made and PIC.loadProject(projectPath) is called. loadProject then creates an internal instance of a PackageInfo class for the project. It parses the package.json file, then creates separate ProjectInfo instances for all internal addons, all CLI in-repo addons and all 'dependency' and 'devDependency' entries. In the process of that it does the same thing that the node resolution algorithm does to find package.json files, but tries as much as possible to not use 'fs.realpathSync' more than once per file path, caching the result of each path resolution for speed.

Because of the cache, reading all the dependencies, parsing their package.json files, determining the addons and collecting information about them (prior to addon-instantiation), current performance on my Mac Pro machine (new 2017 model) has gone from 54 seconds down to 900 milliseconds. Obviously the precise performance will differ based on machine size, but overall it's about a 60x increase.

The result of all the above processing is that the PackageInfoCache contains a number of entries, one per package for each real file path (i.e. its cache entries are keyed on the real path for a package's root directory--all symlinks and .. entries are resolved). A PackageInfo object contains the following fields of interest:

pkg - the object resulting from the package.json file
realPath - (string) the resolved path for the package's root directory
cache - a reference to the PIC object that contains the packageInfo instance
errors - an ErrorList object containing ErrorEntry objects, one per error discovered during the processing of the package and all its various dependencies. When a packageInfo object is being created, if the package itself is bad (e.g. no or malformed package.json file) the error is created and put in the parent. If the package is generally constructed correctly, anything wrong with the contents of the object itself (e.g. one of its dependencies cannot be found), the error is registered with the object. The PIC has a method 'showErrors' to dump a list of all the errors in all the PackageInfo objects. The caller could then decide if any of those errors is enough to fail the build.
Various types of PackageInfo objects can have other fields:

addonConstructor - filled out but not yet used, this would replace part of addons-factory so that constructing an instance of an addon would be done through the PIC/PackageInfo, simplifying the code
addonMainPath - for an addon package, the real path to the package's 'main' file
inRepoAddons - an array of PackageInfo objects, one per in-repo package definition (note that the in-repo addon definition is included in the PIC-level cache by its file path already, this is just a reference to that same set of objects)
internalAddons - same as above, but for internal addons (project only)
cliInfo - same as above, but for the package that corresponds to the path .cli.root
dependencyPackages - a JS object keyed by dependency name of references to PackageInfo objects, one per entry in the package's 'dependency' attribute list)
devDependencyPackages - similar, but for the 'devDependency' attribute list
nodeModules - a NodeModulesList object (basically contains an array of PackageInfo and other NodeModulesList objects) of all packages and subdirectories in a node_modules directory.

dcombslinkedin: Yes, I think those are all that's left, now that eslint is passing. Working on them today.
dcombslinkedin: The latest changes fix issues with running the ember-cli tests (specifically things like supporting 'project.reloadPkg'). At this point I believe no more feature work is needed, and will be adding tests shortly.
kellyselden: > remove typo-fixup stuff, will commit in later PR

I think you should submit that first, so if we were to revert this, it wouldn't be conflicted with the typo stuff on top of it.
dcombslinkedin: Okay, then I'll need to revert the revert (will do after I submit a separate PR - we're only talking about a few lines in about 5 files.) Coming up.
dcombslinkedin: Created the PR https://github.com/ember-cli/ember-cli/pull/7794 for the typo fixes.
dcombslinkedin: Added changes based on @rwjblue comments. Squashed everything into one commit.

Patch:
diff --git a/lib/cli/cli.js b/lib/cli/cli.js
index dd7396069..b1992f7ec 100644
--- a/lib/cli/cli.js
+++ b/lib/cli/cli.js
@@ -7,6 +7,7 @@ const getOptionArgs = require('../utilities/get-option-args');
 const logger = require('heimdalljs-logger')('ember-cli:cli');
 const loggerTesting = require('heimdalljs-logger')('ember-cli:testing');
 const Instrumentation = require('../models/instrumentation');
+const PackageInfoCache = require('../models/package-info-cache');
 const heimdall = require('heimdalljs');
 
 const Promise = RSVP.Promise;
@@ -74,6 +75,8 @@ class CLI {
       initInstrumentation: options.initInstrumentation,
     });
 
+    this.packageInfoCache = new PackageInfoCache(this.ui);
+
     logger.info('testing %o', !!this.testing);
   }
 
@@ -99,6 +102,16 @@ class CLI {
         ui: this.ui,
       });
 
+
+      /*
+       * XXX Need to decide what to do here about showing errors. For
+       * a non-CLI project the cache is local and probably should. For
+       * a CLI project the cache is there, but not sure when we'll know
+       * about all the errors, because there may be multiple projects.
+       *   if (this.packageInfoCache.hasErrors()) {
+       *     this.packageInfoCache.showErrors();
+       *   }
+       */
       let command = new CurrentCommand({
         ui: this.ui,
         analytics: this.analytics,
diff --git a/lib/models/addon-discovery.js b/lib/models/addon-discovery.js
deleted file mode 100644
index 572e3602c..000000000
--- a/lib/models/addon-discovery.js
+++ /dev/null
@@ -1,243 +0,0 @@
-'use strict';
-
-/**
-@module ember-cli
-*/
-
-const logger = require('heimdalljs-logger')('ember-cli:addon-discovery');
-const path = require('path');
-const resolve = require('resolve');
-const heimdall = require('heimdalljs');
-const fs = require('fs');
-
-/**
-  AddonDiscovery is responsible for collecting information about all of the
-  addons that will be used with a project.
-
-  @class AddonDiscovery
-  @extends CoreObject
-  @constructor
-*/
-class AddonDiscovery {
-  constructor(ui) {
-    this.ui = ui;
-  }
-
-  /**
-    This is one of the primary APIs for this class and is called by the project.
-    It returns a tree of plain objects that each contain information about a
-    discovered addon. Each node has `name`, `path`, `pkg` and
-    `childAddons` properties. The latter is an array containing any addons
-    discovered from applying the discovery process to that addon.
-
-    @private
-    @method discoverProjectAddons
-   */
-  discoverProjectAddons(project) {
-    let token = heimdall.start({
-      name: `${project.name()}: addon-discovery`,
-      addonDiscoveryNode: true,
-    });
-    let projectAsAddon = this.discoverFromProjectItself(project);
-    let internalAddons = this.discoverFromInternalProjectAddons(project);
-    let cliAddons = this.discoverFromCli(project.cli);
-    let dependencyAddons;
-
-    dependencyAddons = this.discoverFromDependencies(project.root, project.pkg, false);
-
-    let inRepoAddons = this.discoverInRepoAddons(project.root, project.pkg);
-    let addons = projectAsAddon.concat(cliAddons, internalAddons, dependencyAddons, inRepoAddons);
-
-    token.stop();
-    return addons;
-  }
-
-  /**
-    This is one of the primary APIs for this class and is called by addons.
-    It returns a tree of plain objects that each contain information about a
-    discovered addon. Each node has `name`, `path`, `pkg` and
-    `childAddons` properties. The latter is an array containing any addons
-    discovered from applying the discovery process to that addon.
-
-    @private
-    @method discoverChildAddons
-  */
-  discoverChildAddons(addon) {
-    let token = heimdall.start({
-      name: `${addon.name}: addon-discovery`,
-      addonDiscoveryNode: true,
-    });
-    logger.info('discoverChildAddons: %s(%s)', addon.name, addon.root);
-    let dependencyAddons = this.discoverFromDependencies(addon.root, addon.pkg, true);
-    let inRepoAddons = this.discoverInRepoAddons(addon.root, addon.pkg);
-    let addons = dependencyAddons.concat(inRepoAddons);
-
-    addons = addons.filter(childAddon => !addon.shouldIncludeChildAddon || addon.shouldIncludeChildAddon(childAddon));
-
-    token.stop();
-    return addons;
-  }
-
-  /**
-    Returns an array containing zero or one nodes, depending on whether or not
-    the passed project is an addon.
-
-    @private
-    @method discoverFromProjectItself
-   */
-  discoverFromProjectItself(project) {
-    if (project.isEmberCLIAddon()) {
-      let addonPkg = this.discoverAtPath(project.root);
-      if (addonPkg) {
-        return [addonPkg];
-      }
-    }
-    return [];
-  }
-
-  /**
-    Returns a tree based on the addons referenced in the provided `pkg` through
-    the package.json `dependencies` and optionally `devDependencies` collections,
-    as well as those discovered addons' child addons.
-
-    @private
-    @method discoverFromDependencies
-   */
-  discoverFromDependencies(root, pkg, excludeDevDeps) {
-    let addons = Object.keys(this.dependencies(pkg, excludeDevDeps)).map(name => {
-      if (name !== 'ember-cli') {
-        let addonPath = this.resolvePackage(root, name);
-
-        if (addonPath) {
-          return this.discoverAtPath(addonPath);
-        }
-      }
-    }).filter(Boolean);
-
-    return addons;
-  }
-
-  resolvePackage(root, packageName) {
-    try {
-      // Packages are not required to have a valid Javascript entrypoint, but they are required to have a package.json.
-      let pkgPath = resolve.sync(path.join(packageName, 'package.json'), { basedir: root });
-      return path.dirname(pkgPath);
-
-    } catch (e) {
-      if (e.code === 'MODULE_NOT_FOUND') {
-        return;
-      }
-      throw e;
-    }
-  }
-
-  /**
-    Returns a tree based on the in-repo addons referenced in the provided `pkg`
-    through paths listed in the `ember-addon` entry, as well as those discovered
-    addons' child addons.
-
-    @private
-    @method discoverInRepoAddons
-   */
-  discoverInRepoAddons(root, pkg) {
-    if (!pkg || !pkg['ember-addon'] || !pkg['ember-addon'].paths) {
-      return [];
-    }
-
-    return pkg['ember-addon'].paths
-      .reduce((sum, addonPath) => {
-        let val = this.discoverAtPath(path.join(root, addonPath));
-        if (val) { sum.push(val); }
-        return sum;
-      }, []);
-  }
-
-  /**
-    Returns a tree based on the internal addons that may be defined within the project.
-    It does this by consulting the projects `supportedInternalAddonPaths()` method, which
-    is primarily used for middleware addons.
-
-    @private
-    @method discoverFromInternalProjectAddons
-   */
-  discoverFromInternalProjectAddons(project) {
-    return project.supportedInternalAddonPaths()
-      .reduce((sum, path) => {
-        let val = this.discoverAtPath(path);
-        if (val) { sum.push(val); }
-        return sum;
-      }, []);
-  }
-
-  discoverFromCli(cli) {
-    if (!cli) { return []; }
-
-    const cliPkg = require(path.resolve(cli.root, 'package.json'));
-    return this.discoverInRepoAddons(cli.root, cliPkg);
-  }
-
-  /**
-    Given a particular path, return undefined if the path is not an addon, or if it is,
-    a node with the info about the addon.
-
-    @private
-    @method discoverAtPath
-   */
-  discoverAtPath(addonPath) {
-    let pkgPath = path.join(addonPath, 'package.json');
-    logger.info('attemping to add: %s', addonPath);
-
-    if (fs.existsSync(pkgPath)) {
-      const addonPkg = require(pkgPath);
-      let keywords = addonPkg.keywords || [];
-      logger.info(' - module found: %s', addonPkg.name);
-
-      addonPkg['ember-addon'] = addonPkg['ember-addon'] || {};
-
-      if (keywords.indexOf('ember-addon') > -1) {
-        logger.info(' - is addon, adding...');
-        let addonInfo = {
-          name: addonPkg.name,
-          path: fs.realpathSync(addonPath),
-          pkg: addonPkg,
-        };
-        return addonInfo;
-      } else {
-        logger.info(' - no ember-addon keyword, not including.');
-      }
-    } else {
-      logger.info(` - no package.json (looked at ${pkgPath}).`);
-    }
-
-    return null;
-  }
-
-  /**
-    Returns the dependencies from a package.json
-    @private
-    @method dependencies
-    @param  {Object}  pkg            Package object. If false, the current package is used.
-    @param  {Boolean} excludeDevDeps Whether or not development dependencies should be excluded, defaults to false.
-    @return {Object}                 Dependencies
-   */
-  dependencies(pkg, excludeDevDeps) {
-    pkg = pkg || {};
-
-    let devDependencies = pkg['devDependencies'];
-    if (excludeDevDeps) {
-      devDependencies = {};
-    }
-
-    return Object.assign({}, devDependencies, pkg['dependencies']);
-  }
-
-  addonPackages(addonsList) {
-    return addonsList.reduce((addonPackages, addonPkg) => {
-      addonPackages[addonPkg.name] = addonPkg;
-      return addonPackages;
-    }, {});
-  }
-
-}
-
-module.exports = AddonDiscovery;
diff --git a/lib/models/addon-info.js b/lib/models/addon-info.js
new file mode 100644
index 000000000..c77cfa761
--- /dev/null
+++ b/lib/models/addon-info.js
@@ -0,0 +1,18 @@
+'use strict';
+
+/**
+ * @module addon-info
+ *
+ * A simple class to store metadata info about an addon. This replaces a plain JS object
+ * that used to be created with the same fields.
+ */
+
+class AddonInfo {
+  constructor(name, path, pkg) {
+    this.name = name;
+    this.path = path;
+    this.pkg = pkg;
+  }
+}
+
+module.exports = AddonInfo;
diff --git a/lib/models/addon.js b/lib/models/addon.js
index fc01f1022..7660720ab 100644
--- a/lib/models/addon.js
+++ b/lib/models/addon.js
@@ -12,13 +12,13 @@ const logger = heimdallLogger('ember-cli:addon');
 const treeCacheLogger = heimdallLogger('ember-cli:addon:tree-cache');
 const cacheKeyLogger = heimdallLogger('ember-cli:addon:cache-key-for-tree');
 const experiments = require('../experiments');
+const PackageInfoCache = require('../models/package-info-cache');
 
 const p = require('ember-cli-preprocess-registry/preprocessors');
 const preprocessJs = p.preprocessJs;
 const preprocessCss = p.preprocessCss;
 const preprocessTemplates = p.preprocessTemplates;
 
-const AddonDiscovery = require('./addon-discovery');
 const AddonsFactory = require('./addons-factory');
 const CoreObject = require('core-object');
 const Project = require('./project');
@@ -187,9 +187,8 @@ let addonProto = {
     this.parent = parent;
     this.project = project;
     this.ui = project && project.ui;
-    this.addonPackages = {};
+    this.addonPackages = Object.create(null);
     this.addons = [];
-    this.addonDiscovery = new AddonDiscovery(this.ui);
     this.addonsFactory = new AddonsFactory(this, this.project);
     this.registry = p.defaultRegistry(this);
     this._didRequiredBuildPackages = false;
@@ -197,6 +196,11 @@ let addonProto = {
     if (!this.root) {
       throw new Error('Addon classes must be instantiated with the `root` property');
     }
+
+    if (!this.name) {
+      throw new SilentError(`An addon must define a \`name\` property (found at ${this.root}).`);
+    }
+
     this._nodeModulesPath = null;
 
     this.treePaths = {
@@ -215,11 +219,23 @@ let addonProto = {
 
     this.treeForMethods = defaultsDeep({}, DEFAULT_TREE_FOR_METHODS);
 
+    if (this.parent) {
+      // The parent should already have a packageInfoCache.
+      // Because it does, this package should already be in the cache.
+      this.packageInfoCache = this.parent.packageInfoCache;
+    } else {
+      // this is a 'root' addon, so create a new PackageInfoCache
+      // for it so we get the PackageInfo for it and its children.
+      this.packageInfoCache = new PackageInfoCache(this.ui);
+    }
+
+    this._packageInfo = this.packageInfoCache.loadAddon(this);
+
+    // force us to use the real path as the root.
+    this.root = this._packageInfo.realPath;
+
     p.setupRegistry(this);
 
-    if (!this.name) {
-      throw new SilentError(`An addon must define a \`name\` property (found at ${this.root}).`);
-    }
 
     this._initDefaultBabelOptions();
 
@@ -371,16 +387,36 @@ let addonProto = {
   },
 
   /**
-    Discovers all child addons of this addon and stores their names and
-    package.json contents in this.addonPackages as key-value pairs
-
-    @private
-    @method discoverAddons
+   * Discovers all child addons of this addon and an AddonInfo about
+   * each addon in this.addonPackages (keyed on addon name).
+   *
+   * Child addons include those from 'dependencies' (not devDependencies)
+   * and in-repo addons
+   *
+   * Any packageInfos that we find that are marked as not valid are excluded.
+   *
+   * @private
+   * @method discoverAddons
    */
   discoverAddons() {
-    let addonsList = this.addonDiscovery.discoverChildAddons(this);
+    let pkgInfo = this.packageInfoCache.getEntry(this.root);
+
+    if (pkgInfo) {
+      let self = this;
+      let addonPackageList = pkgInfo.discoverAddonAddons();
+      this.addonPackages =
+          pkgInfo.generateAddonPackages(addonPackageList,
+            addonInfo => (self.shouldIncludeChildAddon && !self.shouldIncludeChildAddon(addonInfo)));
 
-    this.addonPackages = this.addonDiscovery.addonPackages(addonsList);
+      // in case any child addons are invalid, dump to the console about them.
+      pkgInfo.dumpInvalidAddonPackages(addonPackageList);
+    } else {
+      // There are cases where an addon can be created in memory and not actually
+      // have a root entry (or have one that is not actually pointing to a directory,
+      // like 'foo' in some of the tests. We don't want to crash, but want to let
+      // things continue even though they're empty.
+      this.addonPackages = Object.create(null);
+    }
   },
 
   initializeAddons() {
diff --git a/lib/models/package-info-cache/error-entry.js b/lib/models/package-info-cache/error-entry.js
new file mode 100644
index 000000000..2582c37bf
--- /dev/null
+++ b/lib/models/package-info-cache/error-entry.js
@@ -0,0 +1,17 @@
+'use strict';
+
+/*
+ * Small utility class to contain data about a single error found
+ * during loading of a package into the PackageInfoCache.
+ *
+ * @protected
+ * @class ErrorEntry
+ */
+class ErrorEntry {
+  constructor(type, data) {
+    this.type = type;
+    this.data = data;
+  }
+}
+
+module.exports = ErrorEntry;
diff --git a/lib/models/package-info-cache/error-list.js b/lib/models/package-info-cache/error-list.js
new file mode 100644
index 000000000..507e78007
--- /dev/null
+++ b/lib/models/package-info-cache/error-list.js
@@ -0,0 +1,41 @@
+'use strict';
+
+const ErrorEntry = require('./error-entry');
+
+
+/*
+ * Small utility class to store a list of errors during loading of
+ * a package into the PackageInfoCache.
+ *
+ * @protected
+ * @class ErrorList
+ */
+class ErrorList {
+  constructor() {
+    this.errors = [];
+  }
+
+  /*
+   * Add an error. The errorData object is optional, and can be anything.
+   * We do this so we don't really need to create a series of error
+   * classes.
+   *
+   * @public
+   * @param {String} errorType one of the Errors.ERROR_* constants.
+   * @param {Object} errorData any error data relevant to the type of error
+   * being created. See showErrors().
+   */
+  addError(errorType, errorData) {
+    this.errors.push(new ErrorEntry(errorType, errorData));
+  }
+
+  getErrors() {
+    return this.errors;
+  }
+
+  hasErrors() {
+    return this.errors.length > 0;
+  }
+}
+
+module.exports = ErrorList;
diff --git a/lib/models/package-info-cache/errors.js b/lib/models/package-info-cache/errors.js
new file mode 100644
index 000000000..4a606b07d
--- /dev/null
+++ b/lib/models/package-info-cache/errors.js
@@ -0,0 +1,12 @@
+'use strict';
+
+const Errors = {
+  ERROR_PACKAGE_DIR_MISSING: 'packageDirectoryMissing',
+  ERROR_PACKAGE_JSON_MISSING: 'packageJsonMissing',
+  ERROR_PACKAGE_JSON_PARSE: 'packageJsonParse',
+  ERROR_EMBER_ADDON_MAIN_MISSING: 'emberAddonMainMissing',
+  ERROR_DEPENDENCIES_MISSING: 'dependenciesMissing',
+  ERROR_NODEMODULES_ENTRY_MISSING: 'modulesEntryMissing',
+};
+
+module.exports = Errors;
diff --git a/lib/models/package-info-cache/index.js b/lib/models/package-info-cache/index.js
new file mode 100644
index 000000000..d701b78dd
--- /dev/null
+++ b/lib/models/package-info-cache/index.js
@@ -0,0 +1,753 @@
+'use strict';
+
+/*
+ * Performance cache for information about packages (projects/addons/"apps"/modules)
+ * under an initial root directory and resolving addon/dependency links to other packages.
+ */
+
+const fs = require('fs-extra');
+const path = require('path');
+const ErrorList = require('./error-list');
+const Errors = require('./errors');
+const PackageInfo = require('./package-info');
+const NodeModulesList = require('./node-modules-list');
+
+let realFilePathCache;
+let realDirectoryPathCache;
+
+/**
+ * Resolve the real path for a file, return null if does not
+ * exist or is not a file or FIFO, return the real path otherwise.
+ *
+ * @private
+ * @method getRealFilePath
+ * @param  {String} filePath the path to the file of interest
+ * @return {String} real path or null
+ */
+function getRealFilePath(filePath) {
+  let realPath;
+
+  try {
+    realPath = realFilePathCache[filePath];
+
+    if (realPath) {
+      return realPath;
+    }
+
+    let stat = fs.statSync(filePath);
+
+    if (stat.isFile() || stat.isFIFO()) {
+      realPath = fs.realpathSync(filePath);
+    }
+  } catch (e) {
+    if (
+      e !== null &&
+      typeof e === 'object' &&
+      (e.code === 'ENOENT' || e.code === 'ENOTDIR')
+    ) {
+      realPath = null;
+    } else {
+      throw e;
+    }
+  }
+
+  realFilePathCache[filePath] = realPath;
+
+  return realPath;
+}
+
+/**
+ * Resolve the real path for a directory, return null if does not
+ * exist or is not a directory, return the real path otherwise.
+ *
+ * @private
+ * @method getRealDirectoryPath
+ * @param  {String} directoryPath the path to the directory of interest
+ * @return {String} real path or null
+ */
+function getRealDirectoryPath(directoryPath) {
+  let realPath;
+
+  try {
+    realPath = realDirectoryPathCache[directoryPath];
+
+    if (realPath) {
+      return realPath;
+    }
+
+    let stat = fs.statSync(directoryPath);
+
+    if (stat.isDirectory()) {
+      realPath = fs.realpathSync(directoryPath);
+    }
+  } catch (e) {
+    if (
+      e !== null &&
+      typeof e === 'object' &&
+      (e.code === 'ENOENT' || e.code === 'ENOTDIR')
+    ) {
+      realPath = null;
+    } else {
+      throw e;
+    }
+  }
+
+  realDirectoryPathCache[directoryPath] = realPath;
+
+  return realPath;
+}
+
+const PACKAGE_JSON = 'package.json';
+
+/**
+ * Class that stores entries that are either PackageInfo or NodeModulesList objects.
+ * The entries are stored in a map keyed by real directory path.
+ *
+ * @public
+ * @class PackageInfoCache
+ */
+class PackageInfoCache {
+  constructor(ui) {
+    this.ui = ui; // a console-ui instance
+    this._clear();
+  }
+
+  /**
+   * Clear the cache information.
+   *
+   * @private
+   * @method _clear
+   */
+  _clear() {
+    this.entries = Object.create(null);
+    this.projects = [];
+    realFilePathCache = Object.create(null);
+    realDirectoryPathCache = Object.create(null);
+  }
+
+  /**
+   * Indicates if there is at least one error in any object in the cache.
+   *
+   * @public
+   * @method hasErrors
+   * @return true if there are any errors in the cache, for any entries, else false.
+   */
+  hasErrors() {
+    let paths = Object.keys(this.entries);
+
+    if (paths.find(entryPath => this.getEntry(entryPath).hasErrors())) {
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Gather all the errors in the PIC and any cached objects, then dump them
+   * out to the ui-console.
+   *
+   * @public
+   * @method showErrors
+   */
+  showErrors() {
+    let paths = Object.keys(this.entries).sort();
+
+    paths.forEach(entryPath => {
+      this._showObjErrors(this.getEntry(entryPath));
+    });
+  }
+
+  /**
+   * Dump all the errors for a single object in the cache out to the ui-console.
+   *
+   * @private
+   * @method _showObjErrors
+   */
+  _showObjErrors(obj) {
+    if (!obj.hasErrors()) {
+      return;
+    }
+
+    this.ui.writeWarnLine('');
+    let rootPath;
+
+    if (obj instanceof PackageInfoCache) {
+      this.ui.writeWarnLine('Top level errors:');
+      rootPath = this.realPath || '';
+    } else {
+      this.ui.writeWarnLine(`${obj.realPath}:`);
+      rootPath = obj.realPath;
+    }
+
+    let errorList = obj.errors;
+
+    errorList.getErrors().forEach(errorEntry => {
+      switch (errorEntry.type) {
+        case Errors.ERROR_PACKAGE_DIR_MISSING:
+          this.ui.writeWarnLine(`   Missing package directory`);
+          break;
+        case Errors.ERROR_PACKAGE_JSON_MISSING:
+          this.ui.writeWarnLine(`   Missing package.json file`);
+          break;
+        case Errors.ERROR_PACKAGE_JSON_PARSE:
+          this.ui.writeWarnLine(`   Error parsing package.json file`);
+          break;
+        case Errors.ERROR_EMBER_ADDON_MAIN_MISSING:
+          this.ui.writeWarnLine(
+            `   Missing ember-addon 'main' file at relative path '${path.relative(
+              rootPath,
+              errorEntry.data
+            )}'`
+          );
+          break;
+        case Errors.ERROR_DEPENDENCIES_MISSING:
+          if (errorEntry.data.length === 1) {
+            this.ui.writeWarnLine(
+              `   Missing dependency '${errorEntry.data[0]}'`
+            );
+          } else {
+            this.ui.writeWarnLine(`   Missing dependencies:`);
+            errorEntry.data.forEach(dependencyName => {
+              this.ui.writeWarnLine(`      ${dependencyName}`);
+            });
+          }
+          break;
+        case Errors.ERROR_NODEMODULES_ENTRY_MISSING:
+          this.ui.writeWarnLine(`   Missing node_modules entry '[${errorEntry.data}'`);
+          break;
+      }
+    });
+  }
+
+  /**
+   * Process the root directory of a project, given a
+   * Project object (we need the object in order to find the internal addons).
+   * _readPackage takes care of the general processing of the root directory
+   * and common locations for addons, filling the cache with each. Once it
+   * returns, we take care of the locations for addons that are specific to
+   * projects, not other packages (e.g. internal addons, cli root).
+   *
+   * Once all the project processing is done, go back through all cache entries
+   * to create references between the packageInfo objects.
+   *
+   * @public
+   * @method loadProject
+   * @param projectInstance the instance of the Project object to load package data
+   * about into the cache.
+   * @return {PackageInfo} the PackageInfo object for the given Project object.
+   * Note that if the project path is already in the cache, that will be returned.
+   * No copy is made.
+   */
+  loadProject(projectInstance) {
+
+    let pkgInfo = this._readPackage(projectInstance.root, projectInstance.pkg);
+
+    // NOTE: the returned val may contain errors, or may contain
+    // other packages that have errors. We will try to process
+    // things anyway.
+    if (!pkgInfo.processed) {
+      this.projects.push(projectInstance);
+
+      // projects are a bit different than standard addons, in that they have
+      // possibly a CLI addon and internal addons. Add those now.
+      pkgInfo.project = projectInstance;
+
+      if (projectInstance.cli && projectInstance.cli.root) {
+        pkgInfo.cliInfo = this._readPackage(projectInstance.cli.root);
+      }
+
+      // add any internal addons in the project. Since internal addons are
+      // optional (and only used some of the time anyway), we don't want to
+      // create a PackageInfo unless there is really a directory at the
+      // suggested location. The created addon may internally have errors,
+      // as with any other PackageInfo.
+      projectInstance.supportedInternalAddonPaths().forEach(internalAddonPath => {
+        if (getRealDirectoryPath(internalAddonPath)) {
+          pkgInfo.addInternalAddon(this._readPackage(internalAddonPath));
+        }
+      });
+
+      this._resolveDependencies();
+    }
+
+    return pkgInfo;
+  }
+
+  /**
+   * To support the project.reloadPkg method, we need the ability to flush
+   * the cache and reload from the updated package.json.
+   * There are some issues with doing this:
+   *   - Because of the possible relationship between projects and their addons
+   *     due to symlinks, it's not trivial to flush only the data related to a
+   *     given project.
+   *   - If an 'ember-build-cli.js' dynamically adds new projects to the cache,
+   *     we will not necessarily get called again to redo the loading of those
+   *     projects.
+   * The solution, implemented here:
+   *   - Keep track of the Project objects whose packages are loaded into the cache.
+   *   - If a project is reloaded, flush the cache, then do loadPackage again
+   *     for all the known Projects.
+   *
+   * @public
+   * @method reloadProjects
+   * @return null
+   */
+  reloadProjects() {
+    let projects = this.projects.slice();
+    this._clear();
+    projects.forEach(project => this.loadProject(project));
+  }
+
+  /**
+   * Do the actual processing of the root directory of an addon, when the addon
+   * object already exists (i.e. the addon is acting as the root object of a
+   * tree, like project does). We need the object in order to find the internal addons.
+   * _readPackage takes care of the general processing of the root directory
+   * and common locations for addons, filling the cache with each.
+   *
+   * Once all the addon processing is done, go back through all cache entries
+   * to create references between the packageInfo objects.
+   *
+   * @public
+   * @method loadAddon
+   * @param addonInstance the instance of the Addon object to load package data
+   * about into the cache.
+   * @return {PackageInfo} the PackageInfo object for the given Addon object.
+   * Note that if the addon path is already in the cache, that will be returned.
+   * No copy is made.
+   */
+  loadAddon(addonInstance) {
+    let pkgInfo = this._readPackage(addonInstance.root, addonInstance.pkg);
+
+    // NOTE: the returned pkgInfo may contain errors, or may contain
+    // other packages that have errors. We will try to process
+    // things anyway.
+    if (!pkgInfo.processed) {
+      pkgInfo.addon = addonInstance;
+      this._resolveDependencies();
+    }
+
+    return pkgInfo;
+  }
+
+  /**
+   * Resolve the node_module dependencies across all packages after they have
+   * been loaded into the cache, because we don't know when a particular package
+   * will enter the cache.
+   *
+   * Since loadProject can be called multiple times for different projects,
+   * we don't want to reprocess any packages that happen to be common
+   * between them. We'll handle this by marking any packageInfo once it
+   * has been processed here, then ignore it in any later processing.
+   *
+   * @private
+   * @method _resolveDependencies
+   */
+  _resolveDependencies() {
+    let packageInfos = this._getPackageInfos();
+    packageInfos.forEach(pkgInfo => {
+      if (!pkgInfo.processed) {
+        let pkgs = pkgInfo.addDependencies(pkgInfo.pkg.dependencies);
+        if (pkgs) {
+          pkgInfo.dependencyPackages = pkgs;
+        }
+
+        // for Projects only, we also add the devDependencies
+        if (pkgInfo.project) {
+          pkgs = pkgInfo.addDependencies(pkgInfo.pkg.devDependencies);
+          if (pkgs) {
+            pkgInfo.devDependencyPackages = pkgs;
+          }
+        }
+
+        pkgInfo.processed = true;
+      }
+    });
+  }
+
+  /**
+   * Add an entry to the cache.
+   *
+   * @private
+   * @method _addEntry
+   */
+  _addEntry(path, entry) {
+    this.entries[path] = entry;
+  }
+
+  /**
+   * Retrieve an entry from the cache.
+   *
+   * @public
+   * @method getEntry
+   * @param {String} path the real path whose PackageInfo or NodeModulesList is desired.
+   * @return {PackageInfo} or {NodeModulesList} the desired entry.
+   */
+  getEntry(path) {
+    return this.entries[path];
+  }
+
+  /**
+   * Indicate if an entry for a given path exists in the cache.
+   *
+   * @public
+   * @method contains
+   * @param {String} path the real path to check for in the cache.
+   * @return true if the entry is present for the given path, false otherwise.
+   */
+  contains(path) {
+    return this.entries[path] !== undefined;
+  }
+
+  _getPackageInfos() {
+    let result = [];
+
+    Object.keys(this.entries).forEach(path => {
+      let entry = this.entries[path];
+      if (entry instanceof PackageInfo) {
+        result.push(entry);
+      }
+    });
+
+    return result;
+  }
+
+  /*
+   * Find a PackageInfo cache entry with the given path. If there is
+   * no entry in the startPath, do as done in resolve.sync() - travel up
+   * the directory hierarchy, attaching 'node_modules' to each directory and
+   * seeing if the directory exists and has the relevant entry.
+   *
+   * We'll do things a little differently, though, for speed.
+   *
+   * If there is no cache entry, we'll try to use _readNodeModulesList to create
+   * a new cache entry and its contents. If the directory does not exist,
+   * We'll create a NodeModulesList cache entry anyway, just so we don't have
+   * to check with the file system more than once for that directory (we
+   * waste a bit of space, but gain speed by not hitting the file system
+   * again for that path).
+   * Once we have a NodeModulesList, check for the package name, and continue
+   * up the path until we hit the root or the PackageInfo is found.
+   *
+   * @private
+   * @method _findPackage
+   * @param {String} packageName the name/path of the package to search for
+   * @param {String} the path of the directory to start searching from
+   */
+  _findPackage(packageName, startPath) {
+    let parsedPath = path.parse(startPath);
+    let root = parsedPath.root;
+
+    let currPath = startPath;
+
+    while (currPath !== root) {
+      let endsWithNodeModules = path.basename(currPath) === 'node_modules';
+
+      let nodeModulesPath = endsWithNodeModules
+        ? currPath
+        : `${currPath}${path.sep}node_modules`;
+
+      let nodeModulesList = this._readNodeModulesList(nodeModulesPath);
+
+      // _readNodeModulesList only returns a NodeModulesList or null
+      if (nodeModulesList) {
+        let pkg = nodeModulesList.findPackage(packageName);
+        if (pkg) {
+          return pkg;
+        }
+      }
+
+      currPath = path.dirname(currPath);
+    }
+
+    return null;
+  }
+
+  /**
+   * Given a directory that supposedly contains a package, create a PackageInfo
+   * object and try to fill it out, EVEN IF the package.json is not readable.
+   * Errors will then be stored in the PackageInfo for anything with the package
+   * that might be wrong.
+   * Because it's possible that the path given to the packageDir is not actually valid,
+   * we'll just use the path.resolve() version of that path to search for the
+   * path in the cache, before trying to get the 'real' path (which also then
+   * resolves links). The cache itself is keyed on either the realPath, if the
+   * packageDir is actually a real valid directory path, or the normalized path (before
+   * path.resolve()), if it is not.
+   *
+   * NOTE: the cache is also used to store the NULL_PROJECT project object,
+   * which actually has no package.json or other files, but does have an empty
+   * package object. Because of that, and to speed up processing, loadProject()
+   * will pass in both the package root directory path and the project's package
+   * object, if there is one. If the package object is present, we will use that
+   * in preference to trying to find a package.json file.
+   *
+   * If there is no package object, and there is no package.json or the package.json
+   * is bad or the package is an addon with
+   * no main, the only thing we can do is return an ErrorEntry to the caller.
+   * Once past all those problems, if any error occurs with any of the contents
+   * of the package, they'll be cached in the PackageInfo itself.
+   *
+   * In summary, only PackageInfo or ErrorEntry will be returned.
+   *
+   * @private
+   * @method _readPackage
+   * @param {String} pkgDir the path of the directory to read the package.json from and
+   *  process the contents and create a new cache entry or entries.
+   */
+  _readPackage(packageDir, pkg) {
+    let normalizedPackageDir = path.normalize(packageDir);
+
+    // Most of the time, normalizedPackageDir is already a real path (i.e. fs.realpathSync
+    // will return the same value as normalizedPackageDir if the dir actually exists).
+    // Because of that, we'll assume we can test for normalizedPackageDir first and return
+    // if we find it.
+    let pkgInfo = this.getEntry(normalizedPackageDir);
+    if (pkgInfo) {
+      return pkgInfo;
+    }
+
+    // collect errors we hit while trying to create the PackageInfo object.
+    // We'll load these into the object once it's created.
+    let setupErrors = new ErrorList();
+
+    // We don't already have an entry (bad or otherwise) at normalizedPackageDir. See if
+    // we can actually find a real path (including resolving links if needed).
+    let pathFailed = false;
+
+    let realPath = getRealDirectoryPath(normalizedPackageDir);
+
+    if (realPath) {
+      if (realPath !== normalizedPackageDir) {
+        // getRealDirectoryPath actually changed something in the path (e.g.
+        // by resolving a symlink), so see if we have this entry.
+        pkgInfo = this.getEntry(realPath);
+        if (pkgInfo) {
+          return pkgInfo;
+        }
+      } else {
+        // getRealDirectoryPath is same as normalizedPackageDir, and we know already we
+        // don't have an entry there, so we need to create one.
+      }
+    } else {
+      // no realPath, so either nothing is at the path or it's not a directory.
+      // We need to use normalizedPackageDir as the real path.
+      pathFailed = true;
+      setupErrors.addError(Errors.ERROR_PACKAGE_DIR_MISSING, normalizedPackageDir);
+      realPath = normalizedPackageDir;
+    }
+
+    // at this point we have realPath set, we don't already have a PackageInfo
+    // for the path, and the path may or may not actually correspond to a
+    // valid directory (pathFailed tells us which). If we don't have a pkg
+    // object already, we need to be able to read one, unless we also don't
+    // have a path.
+    if (!pkg) {
+      if (!pathFailed) {
+        // we have a valid realPath
+        let packageJsonPath = path.join(realPath, PACKAGE_JSON);
+        let pkgfile = getRealFilePath(packageJsonPath);
+        if (pkgfile) {
+          try {
+            pkg = fs.readJsonSync(pkgfile);
+          } catch (e) {
+            setupErrors.addError(Errors.ERROR_PACKAGE_JSON_PARSE, pkgfile);
+          }
+        } else {
+          setupErrors.addError(Errors.ERROR_PACKAGE_JSON_MISSING, packageJsonPath);
+        }
+      }
+
+      // Some error has occurred resulting in no pkg object, so just
+      // create an empty one so we have something to use below.
+      if (!pkg) {
+        pkg = Object.create(null);
+      }
+    }
+
+    // For storage, force the pkg.root to the calculated path. This will
+    // save us from issues where we have a package for a non-existing
+    // path and other stuff.
+    pkg.root = realPath;
+
+    // Create a new PackageInfo and load any errors as needed.
+    // Note that pkg may be an empty object here.
+    pkgInfo = new PackageInfo(pkg, realPath, this);
+
+    if (setupErrors.hasErrors()) {
+      pkgInfo.errors = setupErrors;
+      pkgInfo.valid = false;
+    }
+
+    // If we have an ember-addon, check that the main exists and points
+    // to a valid file.
+    if (Array.isArray(pkg.keywords) &&
+        pkg.keywords.indexOf('ember-addon') >= 0) {
+
+      // Note: when we have both 'main' and ember-addon:main, the latter takes precedence
+      let main = (pkg['ember-addon'] && pkg['ember-addon'].main) || pkg['main'];
+
+      if (!main || main === '.' || main === './') {
+        main = 'index.js';
+      } else if (!path.extname(main)) {
+        main = `${main}.js`;
+      }
+
+      pkg.main = main;
+
+      let mainPath = path.join(realPath, main);
+      let mainRealPath = getRealFilePath(mainPath);
+
+      if (mainRealPath) {
+        pkgInfo.addonMainPath = mainRealPath;
+      } else {
+        pkgInfo.addError(Errors.ERROR_EMBER_ADDON_MAIN_MISSING, mainPath);
+        this.valid = false;
+      }
+    }
+
+    // The packageInfo itself is now "complete", though we have not
+    // yet dealt with any of its "child" packages. Add it to the
+    // cache
+    this._addEntry(realPath, pkgInfo);
+
+    let emberAddonInfo = pkg['ember-addon'];
+
+    // Set up packageInfos for any in-repo addons
+    if (emberAddonInfo) {
+      let paths = emberAddonInfo.paths;
+
+      if (paths) {
+        paths.forEach(p => {
+          let addonPath = path.join(realPath, p); // real path, though may not exist.
+          let addonPkgInfo = this._readPackage(addonPath); // may have errors in the addon package.
+          pkgInfo.addInRepoAddon(addonPkgInfo);
+        });
+      }
+    }
+
+    // read addon modules from node_modules. We read the whole directory
+    // because it's assumed that npm/yarn may have placed addons in the
+    // directory from lower down in the project tree, and we want to get
+    // the data into the cache ASAP. It may not necessarily be a 'real' error
+    // if we find an issue, if nobody below is actually invoking the addon.
+    let nodeModules = this._readNodeModulesList(path.join(realPath, 'node_modules'));
+
+    if (nodeModules) {
+      pkgInfo.nodeModules = nodeModules;
+    }
+
+    return pkgInfo;
+  }
+
+  /**
+   * Process a directory of modules in a given package directory.
+   *
+   * We will allow cache entries for node_modules that actually
+   * have no contents, just so we don't have to hit the file system more
+   * often than necessary--it's much quicker to check an in-memory object.
+   * object.
+   *
+   * Note: only a NodeModulesList or null is returned.
+   *
+   * @private
+   * @method _readModulesList
+   * @param {String} nodeModulesDir the path of the node_modules directory
+   *  to read the package.json from and process the contents and create a
+   *  new cache entry or entries.
+   */
+  _readNodeModulesList(nodeModulesDir) {
+    let normalizedNodeModulesDir = path.normalize(nodeModulesDir);
+
+    // Much of the time, normalizedNodeModulesDir is already a real path (i.e.
+    // fs.realpathSync will return the same value as normalizedNodeModulesDir, if
+    // the directory actually exists). Because of that, we'll assume
+    // we can test for normalizedNodeModulesDir first and return if we find it.
+    let nodeModulesList = this.getEntry(normalizedNodeModulesDir);
+    if (nodeModulesList) {
+      return nodeModulesList;
+    }
+
+    // NOTE: because we call this when searching for objects in node_modules
+    // directories that may not exist, we'll just return null here if the
+    // directory is not real. If it actually is an error in some case,
+    // the caller can create the error there.
+    let realPath = getRealDirectoryPath(normalizedNodeModulesDir);
+
+    if (!realPath) {
+      return null;
+    }
+
+    // realPath may be different than the original normalizedNodeModulesDir, so
+    // we need to check the cache again.
+    if (realPath !== normalizedNodeModulesDir) {
+      nodeModulesList = this.getEntry(realPath);
+      if (nodeModulesList) {
+        return nodeModulesList;
+      }
+    } else {
+      // getRealDirectoryPath is same as normalizedPackageDir, and we know already we
+      // don't have an entry there, so we need to create one.
+    }
+
+    // At this point we know the directory node_modules exists and we can
+    // process it. Further errors will be recorded here, or in the objects
+    // that correspond to the node_modules entries.
+    nodeModulesList = new NodeModulesList(realPath, this);
+
+    let entries = fs.readdirSync(realPath); // should not fail because getRealDirectoryPath passed
+
+    entries.forEach(entryName => {
+      // entries should be either a package or a scoping directory. I think
+      // there can also be files, but we'll ignore those.
+
+      if (entryName.startsWith('.') || entryName.startsWith('_')) {
+        // we explicitly want to ignore these, according to the
+        // definition of a valid package name.
+        return;
+      }
+
+      let entryPath = path.join(realPath, entryName);
+
+      if (getRealFilePath(entryPath)) {
+        // we explicitly want to ignore valid regular files in node_modules.
+        // This is a bit slower than just checking for directories, but we need to be sure.
+        return;
+      }
+
+      // At this point we have an entry name that should correspond to
+      // a directory, which should turn into either a NodeModulesList or
+      // PackageInfo. If not, it's an error on this NodeModulesList.
+      let entryVal;
+
+      if (entryName.startsWith('@')) {
+        // we should have a scoping directory.
+        entryVal = this._readNodeModulesList(entryPath);
+
+        // readModulesDir only returns NodeModulesList or null
+        if (entryVal instanceof NodeModulesList) {
+          nodeModulesList.addEntry(entryName, entryVal);
+        } else {
+          // This (null return) really should not occur, unless somehow the
+          // dir disappears between the time of fs.readdirSync and now.
+          nodeModulesList.addError(Errors.ERROR_NODEMODULES_ENTRY_MISSING, entryName);
+        }
+      } else {
+        // we should have a package. We will always get a PackageInfo
+        // back, though it may contain errors.
+        entryVal = this._readPackage(entryPath);
+        nodeModulesList.addEntry(entryName, entryVal);
+      }
+    });
+
+    this._addEntry(realPath, nodeModulesList);
+
+    return nodeModulesList;
+  }
+}
+
+module.exports = PackageInfoCache;
+
+// export the getRealXXXPath functions to enable testing
+module.exports.getRealFilePath = getRealFilePath;
+module.exports.getRealDirectoryPath = getRealDirectoryPath;
diff --git a/lib/models/package-info-cache/node-modules-list.js b/lib/models/package-info-cache/node-modules-list.js
new file mode 100644
index 000000000..9a544c2e4
--- /dev/null
+++ b/lib/models/package-info-cache/node-modules-list.js
@@ -0,0 +1,90 @@
+'use strict';
+
+const ErrorList = require('./error-list');
+
+/**
+ * Class that stores information about a node_modules directory (i.e., the
+ * packages and subdirectories in the directory). It is one of the
+ * two types of entries in a PackageInfoCache. It is only created by the
+ * PackageInfoCache.
+ *
+ * @public
+ * @class NodeModulesList
+ */
+class NodeModulesList {
+  constructor(realPath, cache) {
+    this.realPath = realPath;
+    this.cache = cache;
+    this.entries = Object.create(null);
+    this.errors = new ErrorList();
+  }
+
+  /**
+   * Given error data, add an ErrorEntry to the ErrorList for this object.
+   *
+   * @protected
+   * @method addError
+   * @param {String} errorType one of the Errors.ERROR_* constants.
+   * @param {Object} errorData any error data relevant to the type of error
+   * being created. See showErrors().
+   */
+  addError(errorType, errorData) {
+    this.errors.addError(errorType, errorData);
+  }
+
+  /**
+   * Indicate if there are any errors in the NodeModulesList itself (not
+   * including errors within the individual entries).
+   *
+   * @public
+   * @method hasErrors
+   */
+  hasErrors() {
+    return this.errors.hasErrors();
+  }
+
+  /**
+   * Add an entry (PackageInfo or NodeModulesList instance) to the entries
+   * for this list. This is only called by PackageInfoCache. It is not intended
+   * to be called directly by anything else.
+   *
+   * @protected
+   * @method addEntry
+   * @param {String} entryName the name of the entry, i.e., the name of the
+   * file or subdirectory in the directory listing.
+   * @param {Object} entryVal the PackageInfo or NodeModulesList tha corresponds
+   * to the given entry name in the file system.
+   */
+  addEntry(entryName, entryVal) {
+    this.entries[entryName] = entryVal;
+  }
+
+  /**
+   * Return a PackageInfo object for a given package name (which may include
+   * a scope)
+   *
+   * @public
+   * @method findPackage
+   * @param {String} packageName the name (possibly including a scope) of
+   *    the PackageInfo the caller wants returned.
+   * @return the desired PackageInfo if one exists for the given name, else null.
+   */
+  findPackage(packageName) {
+    let val;
+
+    if (packageName.startsWith('@')) {
+      let parts = packageName.split('/');
+      let entry = this.entries[parts[0]]; // scope
+      val =
+        entry instanceof NodeModulesList
+          ? entry.findPackage(parts[1]) // the real name
+          : null;
+    } else {
+      val = this.entries[packageName];
+    }
+
+    return val;
+  }
+}
+
+module.exports = NodeModulesList;
diff --git a/lib/models/package-info-cache/package-info.js b/lib/models/package-info-cache/package-info.js
new file mode 100644
index 000000000..4c3a7c04d
--- /dev/null
+++ b/lib/models/package-info-cache/package-info.js
@@ -0,0 +1,309 @@
+'use strict';
+
+const path = require('path');
+const ErrorList = require('./error-list');
+const Errors = require('./errors');
+const AddonInfo = require('../addon-info');
+
+/**
+ * Class that stores information about a single package (directory tree with
+ * a package.json and other data, like and Addon or Project.) It is one of the
+ * two types of entries in a PackageInfoCache. It is only created by the
+ * PackageInfoCache.
+ *
+ * @public
+ * @class PackageInfo
+ */
+class PackageInfo {
+  constructor(pkgObj, realPath, cache) {
+    this.pkg = pkgObj;
+    this.pkg['ember-addon'] = this.pkg['ember-addon'] || {};
+    this.realPath = realPath;
+    this.cache = cache;
+    this.errors = new ErrorList();
+
+    // other fields that will be set as needed. For JIT we'll define
+    // them here.
+    this.addonMainPath = undefined; // addons only
+    this.inRepoAddons = undefined; // (list of PackageInfo - both)
+    this.internalAddons = undefined; // (list of PackageInfo - project only)
+    this.cliInfo = undefined; // (PackageInfo - project only)
+    this.dependencyPackages = undefined; // (obj keyed by dependency name: PackageInfo)
+    // NOTE: ALL dependencies, not just addons
+    this.devDependencyPackages = undefined; // (obj keyed by devDependency name: PackageInfo)
+    // NOTE: these are ALL dependencies, not just addons
+    this.nodeModules = undefined; // (NodeModulesList, set only if pkg contains node_modules)
+
+    // flag indicating that the packageInfo is considered valid. This will
+    // be true as long as we have a valid directory and our package.json file
+    // is okay and, if we're an ember addon, that we have a valid 'main' file.
+    // Missing dependencies will not be considered an error, since they may
+    // not actually be used.
+    this.valid = true;
+  }
+
+  // Make various fields of the pkg object available.
+  get name() {
+    return this.pkg.name;
+  }
+
+  /**
+   * Given error data, add an ErrorEntry to the ErrorList for this object.
+   * This is used by the _readPackage and _readNodeModulesList methods. It
+   * should not be called otherwise.
+   *
+   * @protected
+   * @method addError
+   * @param {String} errorType one of the Errors.ERROR_* constants.
+   * @param {Object} errorData any error data relevant to the type of error
+   * being created. See showErrors().
+   */
+  addError(errorType, errorData) {
+    this.errors.addError(errorType, errorData);
+  }
+
+  /**
+   * Indicate if there are any errors in the ErrorList for this package. Note that this does
+   * NOT indicate if there are any errors in the objects referred to by this package (e.g.,
+   * internal addons or dependencies).
+   *
+   * @public
+   * @method hasErrors
+   * @param {boolean} true if there are errors in the ErrorList, else false.
+   */
+  hasErrors() {
+    return this.errors.hasErrors();
+  }
+
+  /**
+   * Add a reference to an in-repo addon PackageInfo object.
+   *
+   * @protected
+   * @method addInRepoAddon
+   * @param {PackageInfo} inRepoAddonPkg the PackageInfo for the in-repo addon
+   * @return null
+   */
+  addInRepoAddon(inRepoAddonPkg) {
+    if (!this.inRepoAddons) {
+      this.inRepoAddons = [];
+    }
+    this.inRepoAddons.push(inRepoAddonPkg);
+  }
+
+  /**
+   * Add a reference to an internal addon PackageInfo object.
+   * "internal" addons (note: not in-repo addons) only exist in
+   * Projects, not other packages. Since the cache is loaded from
+   * 'loadProject', this can be done appropriately.
+   *
+   * @protected
+   * @method addInternalAddon
+   * @param {PackageInfo} internalAddonPkg the PackageInfo for the internal addon
+   * @return null
+   */
+  addInternalAddon(internalAddonPkg) {
+    if (!this.internalAddons) {
+      this.internalAddons = [];
+    }
+    this.internalAddons.push(internalAddonPkg);
+  }
+
+  /**
+   * For each dependency in the given list, find the corresponding
+   * PackageInfo object in the cache (going up the file tree if
+   * necessary, as in the node resolution algorithm). Return a map
+   * of the dependencyName to PackageInfo object. Caller can then
+   * store it wherever they like.
+   *
+   * Note: this is not to be  called until all packages that can be have
+   * been added to the cache.
+   *
+   * Note: this is for ALL dependencies, not just addons. To get just
+   * addons, filter the result by calling pkgInfo.isAddon().
+   *
+   * Note: this is only intended for use from PackageInfoCache._resolveDependencies.
+   * It is not to be called directly by anything else.
+   *
+   * @protected
+   * @method addDependencies
+   * @param {PackageInfo} dependencies value of 'dependencies' or 'devDependencies'
+   *        attributes of a package.json.
+   * @return {Object} a JavaScript object keyed on dependency name/path with
+   *    values the corresponding PackageInfo object from the cache.
+   */
+  addDependencies(dependencies) {
+    if (!dependencies) {
+      return null;
+    }
+
+    let dependencyNames = Object.keys(dependencies);
+
+    if (dependencyNames.length === 0) {
+      return null;
+    }
+
+    let packages = Object.create(null);
+
+    let missingDependencies = [];
+
+    dependencyNames.forEach(dependencyName => {
+      let dependencyPackage;
+
+      // much of the time the package will have dependencies in
+      // a node_modules inside it, so check there first because it's
+      // quicker since we have the reference. Only check externally
+      // if we don't find it there.
+      if (this.nodeModules) {
+        dependencyPackage = this.nodeModules.findPackage(dependencyName);
+      }
+
+      if (!dependencyPackage) {
+        dependencyPackage = this.cache._findPackage(
+          dependencyName,
+          path.dirname(this.realPath)
+        );
+      }
+
+      if (dependencyPackage) {
+        packages[dependencyName] = dependencyPackage;
+      } else {
+        missingDependencies.push(dependencyName);
+      }
+    });
+
+    if (missingDependencies.length > 0) {
+      this.addError(Errors.ERROR_DEPENDENCIES_MISSING, missingDependencies);
+    }
+
+    return packages;
+  }
+
+  isAddon() {
+    let val =
+      Array.isArray(this.pkg.keywords) &&
+      this.pkg.keywords.indexOf('ember-addon') >= 0;
+    return val;
+  }
+
+  /**
+   * Add to a list of child addon PackageInfos for this packageInfo.
+   *
+   * @method addPackages
+   * @param {Array} addonPackageList the list of child addon PackageInfos being constructed from various
+   * sources in this packageInfo.
+   * @param {Array | Object} packageInfoList a list or map of PackageInfos being considered
+   * (e.g., pkgInfo.dependencyPackages) for inclusion in the addonPackageList.
+   * @param {Function} excludeFn an optional function. If passed in, each child PackageInfo
+   * will be tested against the function and only included in the package map if the function
+   * returns a truthy value.
+   */
+  addPackages(addonPackageList, packageInfoList, excludeFn) {
+    if (!packageInfoList) {
+      return;
+    }
+
+    if (Array.isArray(packageInfoList)) {
+      if (excludeFn) {
+        packageInfoList = packageInfoList.filter(pkgInfo => !excludeFn(pkgInfo));
+      }
+
+      packageInfoList.forEach(pkgInfo => addonPackageList.push(pkgInfo));
+    } else {
+      // We're going to assume we have a map of name to packageInfo
+      Object.keys(packageInfoList).forEach(name => {
+        let pkgInfo = packageInfoList[name];
+        if (!excludeFn || !excludeFn(pkgInfo)) {
+          addonPackageList.push(pkgInfo);
+        }
+      });
+    }
+
+    return addonPackageList;
+  }
+
+  /**
+   * Discover the child addons for this packageInfo, which corresponds to an Addon.
+   *
+   * @method discoverAddonAddons
+   */
+  discoverAddonAddons() {
+    let addonPackageList = [];
+
+    this.addPackages(addonPackageList,
+      this.dependencyPackages, pkgInfo => !pkgInfo.isAddon() || pkgInfo.name === 'ember-cli');
+    this.addPackages(addonPackageList, this.inRepoAddons);
+
+    return addonPackageList;
+  }
+
+  /**
+   * Discover the child addons for this packageInfo, which corresponds to a Project.
+   *
+   * @method discoverProjectAddons
+   */
+  discoverProjectAddons() {
+    let project = this.project;
+
+    let addonPackageList = [];
+
+    this.addPackages(addonPackageList, (project.isEmberCLIAddon() ? [this] : null));
+    this.addPackages(addonPackageList, (this.cliInfo ? this.cliInfo.inRepoAddons : null));
+    this.addPackages(addonPackageList, this.internalAddons);
+    this.addPackages(addonPackageList, this.dependencyPackages, pkgInfo => !pkgInfo.isAddon());
+    this.addPackages(addonPackageList, this.devDependencyPackages, pkgInfo => !pkgInfo.isAddon());
+    this.addPackages(addonPackageList, this.inRepoAddons);
+
+    return addonPackageList;
+  }
+
+  /**
+   * Given a list of PackageInfo objects, generate the 'addonPackages' object (keyed by
+   * name, value = AddonInfo instance, for all packages marked 'valid'). This is stored in
+   * both Addon and Project instances.
+   *
+   * @method generateAddonPackages
+   * @param {Array} addonPackageList the list of child addon PackageInfos to work from
+   * @param {Function} excludeFn an optional function. If passed in, each child PackageInfo
+   * will be tested against the function and only included in the package map if the function
+   * returns a truthy value.
+   */
+  generateAddonPackages(addonPackageList, excludeFn) {
+    let validPackages = this.getValidPackages(addonPackageList);
+
+    let packageMap = Object.create(null);
+
+    validPackages.forEach(pkgInfo => {
+      let addonInfo = new AddonInfo(pkgInfo.name, pkgInfo.realPath, pkgInfo.pkg);
+      if (!excludeFn || !excludeFn(addonInfo)) {
+        packageMap[pkgInfo.name] = addonInfo;
+      }
+    });
+
+    return packageMap;
+  }
+
+  getValidPackages(addonPackageList) {
+    return addonPackageList.filter(pkgInfo => pkgInfo.valid);
+  }
+
+  getInvalidPackages(addonPackageList) {
+    return addonPackageList.filter(pkgInfo => !pkgInfo.valid);
+  }
+
+  dumpInvalidAddonPackages(addonPackageList) {
+    let invalidPackages = this.getInvalidPackages(addonPackageList);
+
+    let ui = this.cache.ui;
+
+    if (invalidPackages.length > 0 && ui && ui.writeWarnLine) {
+      let typeName = (this.project ? 'project' : 'addon');
+      ui.writeWarnLine(`For ${typeName} at path ${this.realPath}:`);
+      invalidPackages.forEach(packageInfo => {
+        let relativePath = path.relative(this.realPath, packageInfo.realPath);
+        ui.writeWarnLine(`   Excluding invalid/malformed/missing addon at relative path '${relativePath}'`);
+      });
+    }
+  }
+}
+
+module.exports = PackageInfo;
diff --git a/lib/models/project.js b/lib/models/project.js
index a4ba6d3fa..ff5661a05 100644
--- a/lib/models/project.js
+++ b/lib/models/project.js
@@ -14,6 +14,7 @@ const versionUtils = require('../utilities/version-utils');
 const emberCLIVersion = versionUtils.emberCLIVersion;
 const findAddonByName = require('../utilities/find-addon-by-name');
 const heimdall = require('heimdalljs');
+const PackageInfoCache = require('./package-info-cache');
 const experiments = require('../experiments');
 
 let processCwd = process.cwd();
@@ -35,7 +36,6 @@ class Project {
     @param {CLI} cli
   */
   constructor(root, pkg, ui, cli) {
-    const AddonDiscovery = require('../models/addon-discovery');
     const AddonsFactory = require('../models/addons-factory');
 
     logger.info('init root: %s', root);
@@ -48,7 +48,6 @@ class Project {
     this.addons = [];
     this.liveReloadFilterPatterns = [];
     this.setupBowerDirectory();
-    this.addonDiscovery = new AddonDiscovery(this.ui);
     this.addonsFactory = new AddonsFactory(this, this);
     this.configCache = new Map();
 
@@ -76,7 +75,29 @@ class Project {
     instrumentation.project = this;
 
     this.emberCLIVersion = emberCLIVersion;
+
     this._nodeModulesPath = null;
+
+    if (this.cli && this.cli.packageInfoCache) {
+      this.packageInfoCache = this.cli.packageInfoCache;
+    } else {
+      this.packageInfoCache = new PackageInfoCache(this.ui);
+    }
+
+    // we're not dealing with NULL_PROJECT (note that it has not
+    // be set yet, so we can't compare to that var.)
+    this._packageInfo = this.packageInfoCache.loadProject(this);
+
+    // force us to use the real path as the root.
+    this.root = this._packageInfo.realPath;
+
+    // XXX Need to decide what to do here about showing errors. For
+    // a non-CLI project the cache is local and probably should. For
+    // a CLI project the cache is there, but not sure when we'll know
+    // about all the errors, because there may be multiple projects.
+    if (this.packageInfoCache.hasErrors()) {
+      this.packageInfoCache.showErrors();
+    }
   }
 
   /**
@@ -409,16 +430,22 @@ class Project {
   }
 
   /**
-    Discovers all addons for this project and stores their names and
-    package.json contents in this.addonPackages as key-value pairs
-
-    @private
-    @method discoverAddons
+   * Discovers all addons for this project and stores their names and
+   * package.json contents in this.addonPackages as key-value pairs.
+   *
+   * Any packageInfos that we find that are marked as not valid are excluded.
+   *
+   * @private
+   * @method discoverAddons
    */
   discoverAddons() {
-    let addonsList = this.addonDiscovery.discoverProjectAddons(this);
+    let pkgInfo = this.packageInfoCache.getEntry(this.root);
+
+    let addonPackageList = pkgInfo.discoverProjectAddons();
+    this.addonPackages = pkgInfo.generateAddonPackages(addonPackageList);
 
-    this.addonPackages = this.addonDiscovery.addonPackages(addonsList);
+    // in case any child addons are invalid, dump to the console about them.
+    pkgInfo.dumpInvalidAddonPackages(addonPackageList);
   }
 
   /**
@@ -567,6 +594,8 @@ class Project {
     // We use readFileSync instead of require to avoid the require cache.
     this.pkg = fs.readJsonSync(pkgPath);
 
+    this.packageInfoCache.reloadProjects();
+
     return this.pkg;
   }
 


Test patch:
diff --git a/tests/fixtures/addon/external-dependency/index.js b/tests/fixtures/addon/external-dependency/index.js
new file mode 100644
index 000000000..538f6c111
--- /dev/null
+++ b/tests/fixtures/addon/external-dependency/index.js
@@ -0,0 +1,8 @@
+/* eslint-env node */
+module.exports = {
+  name: 'external-dependency',
+
+  isDevelopingAddon() {
+    return true;
+  }
+};
diff --git a/tests/fixtures/addon/external-dependency/package.json b/tests/fixtures/addon/external-dependency/package.json
new file mode 100644
index 000000000..0e404e1c7
--- /dev/null
+++ b/tests/fixtures/addon/external-dependency/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "external-dependency",
+  "version": "0.0.1",
+  "keywords": [
+    "ember-addon"
+  ],
+  "dependencies": {
+    "ember-cli-string-utils": "latest",
+    "@octokit/rest": "latest",
+    "ember-cli-blueprint-test-helpers": "latest"
+  }
+}
diff --git a/tests/helpers/mock-cli.js b/tests/helpers/mock-cli.js
index 6b42673d9..7dda99f03 100644
--- a/tests/helpers/mock-cli.js
+++ b/tests/helpers/mock-cli.js
@@ -3,6 +3,7 @@
 const path = require('path');
 const MockUI = require('console-ui/mock');
 const Instrumentation = require('../../lib/models/instrumentation');
+const PackageInfoCache = require('../../lib/models/package-info-cache');
 
 class MockCLI {
   constructor(options) {
@@ -12,6 +13,7 @@ class MockCLI {
     this.root = path.join(__dirname, '..', '..');
     this.npmPackage = options.npmPackage || 'ember-cli';
     this.instrumentation = options.instrumentation || new Instrumentation({});
+    this.packageInfoCache = new PackageInfoCache(this.ui);
   }
 }
 
diff --git a/tests/helpers/mock-project.js b/tests/helpers/mock-project.js
index 4777e65ae..911c3f996 100644
--- a/tests/helpers/mock-project.js
+++ b/tests/helpers/mock-project.js
@@ -5,7 +5,6 @@ const Instrumentation = require('../../lib/models/instrumentation');
 const MockUI = require('console-ui/mock');
 
 // eslint-disable-next-line node/no-unpublished-require
-const td = require('testdouble');
 
 class MockProject extends Project {
   constructor() {
@@ -24,9 +23,6 @@ class MockProject extends Project {
     };
 
     super(root, pkg, ui, cli);
-
-    let discoverFromCli = td.replace(this.addonDiscovery, 'discoverFromCli');
-    td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
   }
 
   require(file) {
diff --git a/tests/unit/broccoli/ember-app-test.js b/tests/unit/broccoli/ember-app-test.js
index 61976ca03..2b29e0f1d 100644
--- a/tests/unit/broccoli/ember-app-test.js
+++ b/tests/unit/broccoli/ember-app-test.js
@@ -528,8 +528,6 @@ describe('EmberApp', function() {
         const packageContents = require(path.join(projectPath, 'package.json'));
         let cli = new MockCLI();
         project = new Project(projectPath, packageContents, cli.ui, cli);
-        let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-        td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
       });
 
       afterEach(function() {
diff --git a/tests/unit/lint-test.js b/tests/unit/lint-test.js
index dd4e9f8c0..14fad6b62 100644
--- a/tests/unit/lint-test.js
+++ b/tests/unit/lint-test.js
@@ -10,6 +10,6 @@ paths = paths.concat([
 ]);
 
 require('mocha-eslint')(paths, {
-  timeout: 20000,
+  timeout: 30000,
   slow: 1000,
 });
diff --git a/tests/unit/models/addon-discovery-test.js b/tests/unit/models/addon-discovery-test.js
deleted file mode 100644
index e0a537eef..000000000
--- a/tests/unit/models/addon-discovery-test.js
+++ /dev/null
@@ -1,500 +0,0 @@
-'use strict';
-
-const path = require('path');
-const expect = require('chai').expect;
-const Project = require('../../../lib/models/project');
-const AddonDiscovery = require('../../../lib/models/addon-discovery');
-let fixturePath = path.resolve(__dirname, '../../fixtures/addon');
-const MockUI = require('console-ui/mock');
-const MockCLI = require('../../helpers/mock-cli');
-const fs = require('fs');
-
-describe('models/addon-discovery.js', function() {
-  let project, projectPath, ui;
-  this.timeout(40000);
-
-  beforeEach(function() {
-    ui = new MockUI();
-    projectPath = path.resolve(fixturePath, 'simple');
-    const packageContents = require(path.join(projectPath, 'package.json'));
-    let cli = new MockCLI({ ui });
-
-    project = new Project(projectPath, packageContents, ui, cli);
-  });
-
-  describe('dependencies', function() {
-    let mockPkg, deps, devDeps;
-
-    beforeEach(function() {
-      deps = {
-        'foo-bar': 'latest',
-        'blah-blah': '1.0.0',
-      };
-
-      devDeps = {
-        'dev-foo-bar': 'latest',
-      };
-
-      mockPkg = {
-        dependencies: deps,
-        devDependencies: devDeps,
-      };
-    });
-
-    it('returns an object containing dependencies from the provided package.json', function() {
-      let expected = Object.assign({}, deps, devDeps);
-      let discovery = new AddonDiscovery(ui);
-
-      expect(discovery.dependencies(mockPkg)).to.deep.equal(expected);
-    });
-
-    it('excludes development dependencies if instructed', function() {
-      let expected = Object.assign({}, deps);
-      let discovery = new AddonDiscovery(ui);
-
-      expect(discovery.dependencies(mockPkg, true)).to.deep.equal(expected);
-    });
-  });
-
-  describe('discoverFromInternalProjectAddons', function() {
-    it('calls discoverAtPath for each path in project.supportedInternalAddonPaths', function() {
-      let actualPaths = [];
-      let project = {
-        supportedInternalAddonPaths() {
-          return ['lib/foo/', 'baz/qux/'];
-        },
-      };
-
-      let discovery = new AddonDiscovery(ui);
-
-      discovery.discoverAtPath = function(path) {
-        actualPaths.push(path);
-      };
-
-      discovery.discoverFromInternalProjectAddons(project);
-
-      expect(actualPaths).to.deep.equal(project.supportedInternalAddonPaths());
-    });
-  });
-
-  describe('discoverInRepoAddons', function() {
-    describe('returns empty array when ember-addon.paths is empty', function() {
-      let discovery, pkg;
-
-      beforeEach(function() {
-        discovery = new AddonDiscovery(ui);
-      });
-
-      it('returns empty array if `ember-addon` is not present in provided package', function() {
-        pkg = { };
-
-        let actual = discovery.discoverInRepoAddons(fixturePath, pkg);
-        expect(actual).to.deep.equal([]);
-      });
-
-      it('returns empty array if `ember-addon.paths` is missing in provided package', function() {
-        pkg = {
-          'ember-addon': { },
-        };
-
-        let actual = discovery.discoverInRepoAddons(fixturePath, pkg);
-        expect(actual).to.deep.equal([]);
-      });
-
-      it('returns empty array if `ember-addon.paths` is empty in provided package', function() {
-        pkg = {
-          'ember-addon': {
-            paths: [],
-          },
-        };
-
-        let actual = discovery.discoverInRepoAddons(fixturePath, pkg);
-        expect(actual).to.deep.equal([]);
-      });
-    });
-
-    it('calls discoverAtPath for each path in ember-addon.paths', function() {
-      let actualPaths = [];
-      let pkg = {
-        'ember-addon': {
-          paths: ['lib/foo', 'baz/qux'],
-        },
-      };
-      let discovery = new AddonDiscovery(ui);
-
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return providedPath;
-      };
-
-      discovery.discoverInRepoAddons(fixturePath, pkg);
-
-      let expected = [
-        path.join(fixturePath, 'lib', 'foo'),
-        path.join(fixturePath, 'baz', 'qux'),
-      ];
-
-      expect(actualPaths).to.deep.equal(expected);
-    });
-
-    it('falsey results from discoverAtPath are filtered out', function() {
-      let actualPaths = [];
-      let pkg = {
-        'ember-addon': {
-          paths: ['lib/foo', 'baz/qux'],
-        },
-      };
-      let discovery = new AddonDiscovery(ui);
-
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return null;
-      };
-
-      let result = discovery.discoverInRepoAddons(fixturePath, pkg);
-
-      let expectedPaths = [
-        path.join(fixturePath, 'lib', 'foo'),
-        path.join(fixturePath, 'baz', 'qux'),
-      ];
-
-      expect(actualPaths).to.deep.equal(expectedPaths);
-      expect(result).to.deep.equal([]);
-    });
-  });
-
-  describe('discoverFromDependencies', function() {
-    let mockPkg, deps, devDeps;
-
-    beforeEach(function() {
-      deps = {
-        'foo-bar': 'latest',
-        'blah-blah': '1.0.0',
-      };
-
-      devDeps = {
-        'dev-foo-bar': 'latest',
-      };
-
-      mockPkg = {
-        dependencies: deps,
-        devDependencies: devDeps,
-      };
-    });
-
-    it('can find a package without a main entry point [DEPRECATED]', function() {
-      let root = path.join(fixturePath, 'shared-package', 'base');
-      let actualPaths = [];
-      let discovery = new AddonDiscovery(ui);
-
-      deps['invalid-package'] = 'latest';
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return providedPath;
-      };
-
-      discovery.discoverFromDependencies(root, mockPkg, true);
-
-      let expectedPaths = [
-        path.join(root, 'node_modules', 'foo-bar'),
-        path.join(root, 'node_modules', 'blah-blah'),
-        path.join(root, 'node_modules', 'invalid-package'),
-      ];
-
-      expect(actualPaths).to.deep.equal(expectedPaths);
-    });
-
-    it('does not error when dependencies are not found', function() {
-      let root = path.join(fixturePath, 'shared-package', 'base');
-      let actualPaths = [];
-      let discovery = new AddonDiscovery(ui);
-
-      deps['blah-zorz'] = 'latest';
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return providedPath;
-      };
-
-      discovery.discoverFromDependencies(root, mockPkg, true);
-
-      let expectedPaths = [
-        path.join(root, 'node_modules', 'foo-bar'),
-        path.join(root, 'node_modules', 'blah-blah'),
-      ];
-
-      expect(actualPaths).to.deep.equal(expectedPaths);
-    });
-
-    it('calls discoverAtPath for each entry in dependencies', function() {
-      let root = path.join(fixturePath, 'shared-package', 'base');
-      let actualPaths = [];
-      let discovery = new AddonDiscovery(ui);
-
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return providedPath;
-      };
-
-      discovery.discoverFromDependencies(root, mockPkg);
-
-      let expectedPaths = [
-        path.join(root, '..', 'node_modules', 'dev-foo-bar'),
-        path.join(root, 'node_modules', 'foo-bar'),
-        path.join(root, 'node_modules', 'blah-blah'),
-      ];
-
-      expect(actualPaths).to.deep.equal(expectedPaths);
-    });
-
-    it('excludes devDeps if `excludeDevDeps` is true', function() {
-      let root = path.join(fixturePath, 'shared-package', 'base');
-      let actualPaths = [];
-      let discovery = new AddonDiscovery(ui);
-
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return providedPath;
-      };
-
-      discovery.discoverFromDependencies(root, mockPkg, true);
-
-      let expectedPaths = [
-        path.join(root, 'node_modules', 'foo-bar'),
-        path.join(root, 'node_modules', 'blah-blah'),
-      ];
-
-      expect(actualPaths).to.deep.equal(expectedPaths);
-    });
-  });
-
-  describe('discoverFromProjectItself', function() {
-    it('adds the project.root if it is an addon', function() {
-      let project = {
-        isEmberCLIAddon() {
-          return false;
-        },
-      };
-
-      let discovery = new AddonDiscovery(ui);
-      let actual = discovery.discoverFromProjectItself(project);
-
-      expect(actual).to.deep.equal([]);
-    });
-
-    it('returns the root path if the project is an addon', function() {
-      let actualPaths = [];
-      let project = {
-        root: 'foo/bar/baz',
-        isEmberCLIAddon() {
-          return true;
-        },
-      };
-
-      let discovery = new AddonDiscovery(ui);
-
-      discovery.discoverAtPath = function(providedPath) {
-        actualPaths.push(providedPath);
-
-        return providedPath;
-      };
-
-      let actual = discovery.discoverFromProjectItself(project);
-      let expectedPaths = ['foo/bar/baz'];
-
-      expect(actualPaths).to.deep.equal(expectedPaths);
-      expect(actual).to.deep.equal(expectedPaths);
-    });
-  });
-
-  describe('discoverChildAddons', function() {
-    let addon, discovery, discoverFromDependenciesCalled, discoverInRepoAddonsCalled;
-
-    beforeEach(function() {
-      addon = {
-        name: 'awesome-sauce',
-        root: fixturePath,
-        pkg: {
-          dependencies: {
-            'foo-bar': 'latest',
-          },
-          devDependencies: {
-            'dev-dep-bar': 'latest',
-          },
-        },
-      };
-
-      discovery = new AddonDiscovery(ui);
-
-      discovery.discoverFromDependencies = function() {
-        discoverFromDependenciesCalled = true;
-
-        return [];
-      };
-
-      discovery.discoverInRepoAddons = function() {
-        discoverInRepoAddonsCalled = true;
-
-        return [];
-      };
-    });
-
-    it('delegates to discoverInRepoAddons and discoverFromDependencies', function() {
-      discovery.discoverChildAddons(addon);
-
-      expect(discoverInRepoAddonsCalled).to.equal(true);
-      expect(discoverFromDependenciesCalled).to.equal(true);
-    });
-
-    it('concats discoverInRepoAddons and discoverFromDependencies results', function() {
-      discovery.discoverFromDependencies = function() {
-        return ['discoverFromDependencies'];
-      };
-
-      discovery.discoverInRepoAddons = function() {
-        return ['discoverInRepoAddons'];
-      };
-
-      let result = discovery.discoverChildAddons(addon);
-
-      expect(result).to.deep.equal(['discoverFromDependencies', 'discoverInRepoAddons']);
-    });
-
-    it('uses shouldIncludeChildAddon() to determine whether an addon should be included', function() {
-      addon.shouldIncludeChildAddon = function(childAddon) {
-        return childAddon === 'discoverFromDependencies';
-      };
-
-      discovery.discoverFromDependencies = function() {
-        return ['discoverFromDependencies'];
-      };
-
-      discovery.discoverInRepoAddons = function() {
-        return ['discoverInRepoAddons'];
-      };
-
-      let result = discovery.discoverChildAddons(addon);
-      expect(result.length).to.equal(1);
-      expect(result[0]).to.equal('discoverFromDependencies');
-    });
-  });
-
-  describe('discoverProjectAddons', function() {
-    let discovery, discoverFromProjectItselfCalled, discoverFromInternalProjectAddonsCalled, discoverFromDependenciesCalled, discoverInRepoAddonsCalled;
-
-    beforeEach(function() {
-      let cli = new MockCLI();
-      let packageContents = {
-        dependencies: {
-          'foo-bar': 'latest',
-        },
-        devDependencies: {
-          'dev-dep-bar': 'latest',
-        },
-      };
-
-      project = new Project(fixturePath, packageContents, cli.ui, cli);
-
-      discovery = new AddonDiscovery(cli.ui);
-
-      discovery.discoverFromProjectItself = function() {
-        discoverFromProjectItselfCalled = true;
-
-        return ['discoverFromProjectItself'];
-      };
-
-      discovery.discoverFromInternalProjectAddons = function() {
-        discoverFromInternalProjectAddonsCalled = true;
-
-        return ['discoverFromInternalProjectAddons'];
-      };
-
-      discovery.discoverFromDependencies = function() {
-        discoverFromDependenciesCalled = true;
-
-        return ['discoverFromDependencies'];
-      };
-
-      discovery.discoverInRepoAddons = function() {
-        discoverInRepoAddonsCalled = true;
-
-        return ['discoverInRepoAddons'];
-      };
-    });
-
-    it('delegates to internal methods', function() {
-      discovery.discoverProjectAddons(project);
-
-      expect(discoverFromProjectItselfCalled).to.equal(true);
-      expect(discoverFromInternalProjectAddonsCalled).to.equal(true);
-      expect(discoverInRepoAddonsCalled).to.equal(true);
-      expect(discoverFromDependenciesCalled).to.equal(true);
-    });
-
-    it('concats  discoverInRepoAddons and discoverFromDependencies results', function() {
-      let result = discovery.discoverProjectAddons(project);
-
-      expect(result).to.deep.equal([
-        'discoverFromProjectItself',
-        'discoverInRepoAddons', // ember-cli's own in-repo addons
-        'discoverFromInternalProjectAddons',
-        'discoverFromDependencies',
-        'discoverInRepoAddons', // apps in-repo addons
-      ]);
-    });
-  });
-
-  describe('discoverAtPath', function() {
-    it('returns an info object when addon is found', function() {
-      let addonPath = path.join(fixturePath, 'simple/node_modules/ember-random-addon');
-      const addonPkg = require(path.join(addonPath, 'package.json'));
-      let discovery = new AddonDiscovery(ui);
-
-      let result = discovery.discoverAtPath(addonPath);
-
-      expect(result.name).to.equal('ember-random-addon');
-      expect(result.path).to.equal(addonPath);
-      expect(result.pkg).to.deep.equal(addonPkg);
-    });
-
-    it('discovered path resolves symlinks', function() {
-      let addonPath = path.join(fixturePath, 'simple/node_modules/symlinked-addon');
-      let targetPath = path.join(fixturePath, 'simple/node_modules/ember-random-addon');
-
-      try {
-        // remove any prior symlink that's floating around here
-        fs.unlinkSync(addonPath);
-      } catch (err) {
-        // allowed to fail because symlink may not have existed
-      }
-
-      // Make our symlink. We do it here instead of committing the
-      // symlink to our repo because checking out repos with symlinks
-      // does the wrong thing in Windows non-administrator shells.
-      fs.symlinkSync(targetPath, addonPath);
-
-      const addonPkg = require(path.join(addonPath, 'package.json'));
-      let discovery = new AddonDiscovery(ui);
-
-      let result = discovery.discoverAtPath(addonPath);
-
-      expect(result.name).to.equal('ember-random-addon');
-      expect(result.path).to.equal(addonPath.replace('symlinked-addon', 'ember-random-addon'));
-      expect(result.pkg).to.deep.equal(addonPkg);
-    });
-
-    it('returns `null` if path is not for an addon', function() {
-      let addonPath = path.join(fixturePath, 'simple');
-      let discovery = new AddonDiscovery(ui);
-
-      let result = discovery.discoverAtPath(addonPath);
-
-      expect(result).to.be.null;
-    });
-  });
-});
diff --git a/tests/unit/models/addon-test.js b/tests/unit/models/addon-test.js
index fd9f73f22..36cadac85 100644
--- a/tests/unit/models/addon-test.js
+++ b/tests/unit/models/addon-test.js
@@ -207,8 +207,6 @@ describe('models/addon.js', function() {
       let ui = new MockUI();
       let cli = new MockCLI({ ui });
       project = new Project(projectPath, packageContents, ui, cli);
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
       project.initializeAddons();
     });
 
@@ -623,8 +621,6 @@ describe('models/addon.js', function() {
       let cli = new MockCLI();
 
       project = new Project(projectPath, packageContents, cli.ui, cli);
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
 
       project.initializeAddons();
 
@@ -647,8 +643,6 @@ describe('models/addon.js', function() {
       let cli = new MockCLI();
 
       project = new Project(projectPath, packageContents, cli.ui, cli);
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
 
       project.initializeAddons();
 
@@ -764,8 +758,8 @@ describe('models/addon.js', function() {
     });
   });
 
-  describe('addonDiscovery', function() {
-    let discovery, addon, ui;
+  describe('packageInfoCache', function() {
+    let packageInfoCache, addon, ui;
 
     beforeEach(function() {
       projectPath = path.resolve(fixturePath, 'simple');
@@ -781,11 +775,11 @@ describe('models/addon.js', function() {
       });
 
       addon = new AddonTemp(project, project);
-      discovery = addon.addonDiscovery;
+      packageInfoCache = addon.packageInfoCache;
     });
 
-    it('is provided with the addon\'s `ui` object', function() {
-      expect(discovery.ui).to.equal(ui);
+    it('is provided with the parent\'s `packageInfoCache` object', function() {
+      expect(packageInfoCache).to.equal(project.packageInfoCache);
     });
   });
 
diff --git a/tests/unit/models/package-info-cache-test.js b/tests/unit/models/package-info-cache-test.js
new file mode 100644
index 000000000..afba512f9
--- /dev/null
+++ b/tests/unit/models/package-info-cache-test.js
@@ -0,0 +1,293 @@
+'use strict';
+
+const path = require('path');
+const expect = require('chai').expect;
+const PackageInfoCache = require('../../../lib/models/package-info-cache');
+const Project = require('../../../lib/models/project');
+let addonFixturePath = path.resolve(__dirname, '../../fixtures/addon');
+const MockUI = require('console-ui/mock');
+const MockCLI = require('../../helpers/mock-cli');
+
+describe('models/package-info-cache.js', function() {
+  let project, projectPath, packageJsonPath, packageContents, projectPackageInfo, ui, cli, pic;
+  this.timeout(20000);
+
+  beforeEach(function() {
+    ui = new MockUI();
+    cli = new MockCLI({ ui });
+  });
+
+  describe('packageInfo contents tests on valid project', function() {
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'simple');
+      packageJsonPath = path.join(projectPath, 'package.json');
+      packageContents = require(packageJsonPath);
+      project = new Project(projectPath, packageContents, ui, cli);
+      pic = project.packageInfoCache;
+
+      projectPackageInfo = pic.getEntry(projectPath);
+    });
+
+    it('finds project PackageInfo entry for project root', function() {
+      expect(projectPackageInfo).to.exist;
+    });
+
+    it('projectPackageInfo has a "pkg" field', function() {
+      expect(projectPackageInfo.pkg).to.exist;
+    });
+
+    it('shows projectPackageInfo is considered valid', function() {
+      expect(projectPackageInfo.valid).to.be.true;
+    });
+
+    it('shows projectPackageInfo has cliInfo at ember-cli root dir', function() {
+      expect(projectPackageInfo.cliInfo).to.exist;
+
+      let cliRealPath = projectPackageInfo.cliInfo.realPath;
+      let emberCliRealPath = path.resolve(`${projectPackageInfo.realPath}/../../../../`);
+      expect(cliRealPath).to.equal(emberCliRealPath);
+    });
+
+    it('shows projectPackageInfo has 1 error', function() {
+      expect(projectPackageInfo.hasErrors()).to.be.true;
+
+      let errorArray = projectPackageInfo.errors.getErrors();
+
+      expect(errorArray.length).to.equal(1);
+    });
+
+    it('shows projectPackageInfo error is "3 dependencies missing"', function() {
+      let errorArray = projectPackageInfo.errors.getErrors();
+      let error = errorArray[0];
+      expect(error.type).to.equal('dependenciesMissing');
+      expect(error.data.length).to.equal(3);
+    });
+
+    it('shows projectPackageInfo has 1 dependencyPackage', function() {
+      let dependencyPackages = projectPackageInfo.dependencyPackages;
+
+      expect(dependencyPackages).to.exist;
+      expect(Object.keys(dependencyPackages).length).to.equal(1);
+      expect(dependencyPackages['something-else']).to.exist;
+    });
+
+    it('shows projectPackageInfo has 8 devDependencyPackages', function() {
+      let devDependencyPackages = projectPackageInfo.devDependencyPackages;
+      expect(devDependencyPackages).to.exist;
+      expect(Object.keys(devDependencyPackages).length).to.equal(8);
+    });
+
+    it('shows projectPackageInfo.devDependencyPackages + missing dependencies = project.devDependencies', function() {
+      let devDependencyPackages = projectPackageInfo.devDependencyPackages;
+      expect(devDependencyPackages).to.exist;
+      let devDependencyPackageNames = Object.keys(devDependencyPackages);
+
+      let devDependencies = projectPackageInfo.pkg.devDependencies;
+      expect(devDependencies).to.exist;
+      let devDependencyNames = Object.keys(devDependencies).sort();
+
+      let errorArray = projectPackageInfo.errors.getErrors();
+      let error = errorArray[0];
+      expect(error.type).to.equal('dependenciesMissing');
+
+      let missingDependencies = error.data;
+
+      let packageAndErrorNames = devDependencyPackageNames.concat(missingDependencies).sort();
+
+      expect(packageAndErrorNames).to.deep.equal(devDependencyNames);
+    });
+
+    it('shows projectPackageInfo has 1 in-repo addon named "ember-super-button"', function() {
+      let inRepoAddons = projectPackageInfo.inRepoAddons;
+      expect(inRepoAddons).to.exist;
+      expect(inRepoAddons.length).to.equal(1);
+      expect(inRepoAddons[0].realPath.indexOf(`simple${path.sep}lib${path.sep}ember-super-button`)).to.be.above(0);
+      expect(inRepoAddons[0].pkg.name).to.equal('ember-super-button');
+    });
+
+    it('shows projectPackageInfo has 7 internal addon packages', function() {
+      let internalAddons = projectPackageInfo.internalAddons;
+      expect(internalAddons).to.exist;
+      expect(internalAddons.length).to.equal(7);
+    });
+
+    it('shows projectPackageInfo has 9 node-module entries', function() {
+      let nodeModules = projectPackageInfo.nodeModules;
+      expect(nodeModules).to.exist;
+      expect(nodeModules.entries).to.exist;
+      expect(Object.keys(nodeModules.entries).length).to.equal(9);
+    });
+
+  });
+
+  describe('packageInfo contents tests on missing project', function() {
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'fakepackage');
+
+      let deps = {
+        'foo-bar': 'latest',
+        'blah-blah': '1.0.0',
+      };
+
+      let devDeps = {
+        'dev-foo-bar': 'latest',
+      };
+
+      packageContents = {
+        dependencies: deps,
+        devDependencies: devDeps,
+      };
+
+      project = new Project(projectPath, packageContents, ui, cli);
+
+      pic = project.packageInfoCache;
+      projectPackageInfo = pic.getEntry(projectPath);
+    });
+
+    it('creates a packageInfo object for the missing path', function() {
+      expect(projectPackageInfo).to.exist;
+    });
+
+    it('has 3 errors', function() {
+      let errors = projectPackageInfo.errors;
+      expect(errors).to.exist;
+      expect(errors.hasErrors()).to.be.true;
+      expect(errors.getErrors().length).to.equal(3);
+    });
+
+    it('has a "packageDirectoryMissing" error', function() {
+      let errorArray = projectPackageInfo.errors.getErrors();
+      let pkgDirMissingErr = errorArray.find(function(err) {
+        return err.type === 'packageDirectoryMissing';
+      });
+      expect(pkgDirMissingErr).to.exist;
+      expect(pkgDirMissingErr.data).to.equal(projectPath);
+    });
+
+    it('has empty "dependencyPackages" and "devDependencyPackages" objects', function() {
+      expect(projectPackageInfo.dependencyPackages).to.exist;
+      expect(projectPackageInfo.devDependencyPackages).to.exist;
+      expect(Object.keys(projectPackageInfo.dependencyPackages).length).to.equal(0);
+      expect(Object.keys(projectPackageInfo.devDependencyPackages).length).to.equal(0);
+    });
+  });
+
+  describe('packageInfo contents tests on with-nested-addons project', function() {
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'with-nested-addons');
+      packageJsonPath = path.join(projectPath, 'package.json');
+      packageContents = null; // there is no actual package.json
+      project = new Project(projectPath, packageContents, ui, cli);
+
+      pic = project.packageInfoCache;
+      projectPackageInfo = pic.getEntry(projectPath);
+    });
+
+    it('shows projectPackageInfo has a "packageJsonMissing" error', function() {
+      let errorArray = projectPackageInfo.errors.getErrors();
+      let pkgJsonMissingErr = errorArray.find(function(err) {
+        return err.type === 'packageJsonMissing';
+      });
+      expect(pkgJsonMissingErr).to.exist;
+      expect(pkgJsonMissingErr.data).to.equal(packageJsonPath);
+    });
+
+  });
+
+  describe('packageInfo contents tests on external-dependency project', function() {
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'external-dependency');
+      packageJsonPath = path.join(projectPath, 'package.json');
+      packageContents = require(packageJsonPath);
+      project = new Project(projectPath, packageContents, ui, cli);
+
+      pic = project.packageInfoCache;
+      projectPackageInfo = pic.getEntry(projectPath);
+    });
+
+    it('shows projectPackageInfo finds a dependency above project root', function() {
+      expect(projectPackageInfo.dependencyPackages).to.exist;
+
+      let emberCliStringUtilsPkgInfo = projectPackageInfo.dependencyPackages['ember-cli-string-utils'];
+      expect(emberCliStringUtilsPkgInfo).to.exist;
+
+      let emberCliRealPath = path.resolve(`${projectPackageInfo.realPath}/../../../../`);
+      expect(emberCliStringUtilsPkgInfo.realPath).to.equal(path.join(emberCliRealPath, 'node_modules', 'ember-cli-string-utils'));
+    });
+
+    it('shows projectPackageInfo finds an external dependency involving a scope', function() {
+      expect(projectPackageInfo.dependencyPackages).to.exist;
+
+      let restPkgInfo = projectPackageInfo.dependencyPackages['@octokit/rest'];
+      expect(restPkgInfo).to.exist;
+
+      let emberCliRealPath = path.resolve(`${projectPackageInfo.realPath}/../../../../`);
+      expect(restPkgInfo.realPath).to.equal(path.join(emberCliRealPath, 'node_modules', '@octokit', 'rest'));
+    });
+  });
+
+  describe('discoverProjectAddons tests on external-dependency project', function() {
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'external-dependency');
+      packageJsonPath = path.join(projectPath, 'package.json');
+      packageContents = require(packageJsonPath);
+      project = new Project(projectPath, packageContents, ui, cli);
+      project.discoverAddons();
+
+      pic = project.packageInfoCache;
+      projectPackageInfo = pic.getEntry(projectPath);
+    });
+
+    it('shows dependency packages exist and are in project.addonPackages', function() {
+      expect(project.addonPackages).to.exist;
+      let packageNames = Object.keys(project.addonPackages);
+      expect(packageNames.length).to.equal(9);
+      expect(packageNames.indexOf('ember-cli-blueprint-test-helpers')).to.be.above(-1);
+    });
+  });
+
+  describe('getRealFilePath tests', function() {
+    let fakePackageJsonPath;
+
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'external-dependency');
+      packageJsonPath = path.join(projectPath, 'package.json');
+      fakePackageJsonPath = path.join(projectPath, 'foozleberry.js');
+    });
+
+    it('getRealFilePath(real package.json file) exists', function() {
+      expect(PackageInfoCache.getRealFilePath(packageJsonPath)).to.exist;
+    });
+
+    it('getRealFilePath(fake file path) does not exist', function() {
+      expect(PackageInfoCache.getRealFilePath(fakePackageJsonPath)).not.to.exist;
+    });
+
+    it('getRealFilePath(dir path) does not exist', function() {
+      expect(PackageInfoCache.getRealFilePath(projectPath)).not.to.exist;
+    });
+  });
+
+  describe('getRealDirectoryPath tests', function() {
+    let fakePackageJsonPath;
+
+    beforeEach(function() {
+      projectPath = path.resolve(addonFixturePath, 'external-dependency');
+      packageJsonPath = path.join(projectPath, 'package.json');
+      fakePackageJsonPath = path.join(projectPath, 'foozleberry.js');
+    });
+
+    it('getRealDirectoryPath(real package directory) exists', function() {
+      expect(PackageInfoCache.getRealDirectoryPath(projectPath)).to.exist;
+    });
+
+    it('getRealDirectoryPath(fake path) does not exist', function() {
+      expect(PackageInfoCache.getRealDirectoryPath(fakePackageJsonPath)).not.to.exist;
+    });
+
+    it('getRealDirectoryPath(real file path) does not exist', function() {
+      expect(PackageInfoCache.getRealDirectoryPath(packageJsonPath)).not.to.exist;
+    });
+  });
+
+});
diff --git a/tests/unit/models/project-test.js b/tests/unit/models/project-test.js
index a087466f4..0671e25e9 100644
--- a/tests/unit/models/project-test.js
+++ b/tests/unit/models/project-test.js
@@ -70,8 +70,6 @@ describe('models/project.js', function() {
 
           makeProject();
 
-          let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-          td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
           project.require = function() {
             called = true;
             return function() {};
@@ -238,8 +236,7 @@ describe('models/project.js', function() {
           );
 
           makeProject();
-          let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-          td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
+
           project.require = function() {
             return { browsers: ["last 2 versions", "safari >= 7"] };
           };
@@ -257,8 +254,6 @@ describe('models/project.js', function() {
       beforeEach(function() {
         return tmp.setup(projectPath).then(function() {
           makeProject();
-          let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-          td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
         });
       });
 
@@ -281,8 +276,7 @@ describe('models/project.js', function() {
       packageContents = require(path.join(projectPath, 'package.json'));
 
       makeProject();
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
+
       project.initializeAddons();
     });
 
@@ -416,19 +410,12 @@ describe('models/project.js', function() {
     });
 
     it('adds the project itself if it is an addon', function() {
-      let added = false;
       project.addonPackages = {};
       project.isEmberCLIAddon = function() { return true; };
 
-      project.addonDiscovery.discoverAtPath = function(path) {
-        if (path === project.root) {
-          added = true;
-        }
-      };
-
       project.discoverAddons();
 
-      expect(added).to.be.ok;
+      expect(project.addonPackages[project.name()]).to.exist;
     });
 
     it('should catch addon constructor errors', function() {
@@ -454,8 +441,6 @@ describe('models/project.js', function() {
 
       makeProject();
 
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
       project.initializeAddons();
 
       td.replace(Project.prototype, 'initializeAddons', td.function());
@@ -488,8 +473,7 @@ describe('models/project.js', function() {
       packageContents = require(path.join(projectPath, 'package.json'));
 
       makeProject();
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
+
       project.initializeAddons();
 
       newProjectPath = path.resolve(__dirname, '../../fixtures/addon/env-addons');
@@ -545,8 +529,6 @@ describe('models/project.js', function() {
       projectPath = `${process.cwd()}/tmp/test-app`;
 
       makeProject();
-      let discoverFromCli = td.replace(project.addonDiscovery, 'discoverFromCli');
-      td.when(discoverFromCli(), { ignoreExtraArgs: true }).thenReturn([]);
       project.initializeAddons();
     });