diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -107,6 +107,7 @@
     'CelerityResourceController' => 'infrastructure/celerity/CelerityResourceController.php',
     'CelerityResourceGraph' => 'infrastructure/celerity/CelerityResourceGraph.php',
     'CelerityResourceMap' => 'infrastructure/celerity/CelerityResourceMap.php',
+    'CelerityResourceMapGenerator' => 'infrastructure/celerity/CelerityResourceMapGenerator.php',
     'CelerityResourceTransformer' => 'infrastructure/celerity/CelerityResourceTransformer.php',
     'CelerityResourceTransformerTestCase' => 'infrastructure/celerity/__tests__/CelerityResourceTransformerTestCase.php',
     'CelerityResources' => 'infrastructure/celerity/resources/CelerityResources.php',
@@ -1357,6 +1358,7 @@
     'PhabricatorCalendarPHIDTypeEvent' => 'applications/calendar/phid/PhabricatorCalendarPHIDTypeEvent.php',
     'PhabricatorCalendarViewController' => 'applications/calendar/controller/PhabricatorCalendarViewController.php',
     'PhabricatorCampfireProtocolAdapter' => 'infrastructure/daemon/bot/adapter/PhabricatorCampfireProtocolAdapter.php',
+    'PhabricatorCelerityTestCase' => '__tests__/PhabricatorCelerityTestCase.php',
     'PhabricatorChangeParserTestCase' => 'applications/repository/worker/__tests__/PhabricatorChangeParserTestCase.php',
     'PhabricatorChangesetResponse' => 'infrastructure/diff/PhabricatorChangesetResponse.php',
     'PhabricatorChatLogChannel' => 'applications/chatlog/storage/PhabricatorChatLogChannel.php',
@@ -4168,6 +4170,7 @@
     'PhabricatorCalendarPHIDTypeEvent' => 'PhabricatorPHIDType',
     'PhabricatorCalendarViewController' => 'PhabricatorCalendarController',
     'PhabricatorCampfireProtocolAdapter' => 'PhabricatorBotBaseStreamingProtocolAdapter',
+    'PhabricatorCelerityTestCase' => 'PhabricatorTestCase',
     'PhabricatorChangeParserTestCase' => 'PhabricatorWorkingCopyTestCase',
     'PhabricatorChangesetResponse' => 'AphrontProxyResponse',
     'PhabricatorChatLogChannel' =>
diff --git a/src/__tests__/PhabricatorCelerityTestCase.php b/src/__tests__/PhabricatorCelerityTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/__tests__/PhabricatorCelerityTestCase.php
@@ -0,0 +1,33 @@
+<?php
+
+final class PhabricatorCelerityTestCase extends PhabricatorTestCase {
+
+  /**
+   * This is more of an acceptance test case instead of a unit test. It verifies
+   * that the Celerity map is up-to-date.
+   */
+  public function testCelerityMaps() {
+    $resources_map = CelerityPhysicalResources::getAll();
+
+    foreach ($resources_map as $resources) {
+      $old_map = new CelerityResourceMap($resources);
+
+      $new_map = id(new CelerityResourceMapGenerator($resources))
+        ->generate();
+
+      $this->assertEqual(
+        $new_map->getNameMap(),
+        $old_map->getNameMap());
+      $this->assertEqual(
+        $new_map->getSymbolMap(),
+        $old_map->getSymbolMap());
+      $this->assertEqual(
+        $new_map->getRequiresMap(),
+        $old_map->getRequiresMap());
+      $this->assertEqual(
+        $new_map->getPackageMap(),
+        $old_map->getPackageMap());
+    }
+  }
+
+}
diff --git a/src/infrastructure/celerity/CelerityResourceMap.php b/src/infrastructure/celerity/CelerityResourceMap.php
--- a/src/infrastructure/celerity/CelerityResourceMap.php
+++ b/src/infrastructure/celerity/CelerityResourceMap.php
@@ -53,6 +53,22 @@
     return self::$instances[$name];
   }
 
+  public function getNameMap() {
+    return $this->nameMap;
+  }
+
+  public function getSymbolMap() {
+    return $this->symbolMap;
+  }
+
+  public function getRequiresMap() {
+    return $this->requiresMap;
+  }
+
+  public function getPackageMap() {
+    return $this->packageMap;
+  }
+
   public function getPackagedNamesForSymbols(array $symbols) {
     $resolved = $this->resolveResources($symbols);
     return $this->packageResources($resolved);
diff --git a/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php b/src/infrastructure/celerity/CelerityResourceMapGenerator.php
copy from src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php
copy to src/infrastructure/celerity/CelerityResourceMapGenerator.php
--- a/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php
+++ b/src/infrastructure/celerity/CelerityResourceMapGenerator.php
@@ -1,64 +1,59 @@
 <?php
 
-final class CelerityManagementMapWorkflow
-  extends CelerityManagementWorkflow {
-
-  public function didConstruct() {
-    $this
-      ->setName('map')
-      ->setExamples('**map** [options]')
-      ->setSynopsis(pht('Rebuild static resource maps.'))
-      ->setArguments(
-        array());
+final class CelerityResourceMapGenerator {
+
+  private $debug = false;
+  private $resources;
+
+  private $nameMap     = array();
+  private $symbolMap   = array();
+  private $requiresMap = array();
+  private $packageMap  = array();
+
+  public function __construct(CelerityPhysicalResources $resources) {
+    $this->resources = $resources;
   }
 
-  public function execute(PhutilArgumentParser $args) {
-    $resources_map = CelerityPhysicalResources::getAll();
+  public function getNameMap() {
+    return $this->nameMap;
+  }
 
-    $this->log(
-      pht(
-        'Rebuilding %d resource source(s).',
-        new PhutilNumber(count($resources_map))));
+  public function getSymbolMap() {
+    return $this->symbolMap;
+  }
 
-    foreach ($resources_map as $name => $resources) {
-      $this->rebuildResources($resources);
-    }
+  public function getRequiresMap() {
+    return $this->requiresMap;
+  }
 
-    $this->log(pht('Done.'));
+  public function getPackageMap() {
+    return $this->packageMap;
+  }
 
-    return 0;
+  public function setDebug($debug) {
+    $this->debug = $debug;
+    return $this;
   }
 
-  /**
-   * Rebuild the resource map for a resource source.
-   *
-   * @param CelerityPhysicalResources Resource source to rebuild.
-   * @return void
-   */
-  private function rebuildResources(CelerityPhysicalResources $resources) {
-    $this->log(
-      pht(
-        'Rebuilding resource source "%s" (%s)...',
-        $resources->getName(),
-        get_class($resources)));
+  protected function log($message) {
+    if ($this->debug) {
+      $console = PhutilConsole::getConsole();
+      $console->writeErr("%s\n", $message);
+    }
+  }
 
-    $binary_map = $this->rebuildBinaryResources($resources);
+  public function generate() {
+    $binary_map = $this->rebuildBinaryResources($this->resources);
 
-    $this->log(
-      pht(
-        'Found %d binary resources.',
-        new PhutilNumber(count($binary_map))));
+    $this->log(pht('Found %d binary resources.', count($binary_map)));
 
     $xformer = id(new CelerityResourceTransformer())
       ->setMinify(false)
       ->setRawURIMap(ipull($binary_map, 'uri'));
 
-    $text_map = $this->rebuildTextResources($resources, $xformer);
+    $text_map = $this->rebuildTextResources($this->resources, $xformer);
 
-    $this->log(
-      pht(
-        'Found %d text resources.',
-        new PhutilNumber(count($text_map))));
+    $this->log(pht('Found %d text resources.', count($text_map)));
 
     $resource_graph = array();
     $requires_map = array();
@@ -81,14 +76,11 @@
     $hash_map = array_flip($name_map);
 
     $package_map = $this->rebuildPackages(
-      $resources,
+      $this->resources,
       $symbol_map,
       $hash_map);
 
-    $this->log(
-      pht(
-        'Found %d packages.',
-        new PhutilNumber(count($package_map))));
+    $this->log(pht('Found %d packages.', count($package_map)));
 
     $component_map = array();
     foreach ($package_map as $package_name => $package_info) {
@@ -110,18 +102,46 @@
     ksort($requires_map);
     ksort($package_map);
 
+    $this->nameMap     = $name_map;
+    $this->symbolMap   = $symbol_map;
+    $this->requiresMap = $requires_map;
+    $this->packageMap  = $package_map;
+
+    return $this;
+  }
+
+  public function write() {
     $map_content = $this->formatMapContent(array(
-      'names' => $name_map,
-      'symbols' => $symbol_map,
-      'requires' => $requires_map,
-      'packages' => $package_map,
+      'names'    => $this->getNameMap(),
+      'symbols'  => $this->getSymbolMap(),
+      'requires' => $this->getRequiresMap(),
+      'packages' => $this->getPackageMap(),
     ));
 
-    $map_path = $resources->getPathToMap();
+    $map_path = $this->resources->getPathToMap();
     $this->log(pht('Writing map "%s".', Filesystem::readablePath($map_path)));
     Filesystem::writeFile($map_path, $map_content);
+
+    return $this;
   }
 
+  private function formatMapContent(array $data) {
+    $content = var_export($data, true);
+    $content = preg_replace('/\s+$/m', '', $content);
+    $content = preg_replace('/array \(/', 'array(', $content);
+
+    $generated = '@'.'generated';
+    return <<<EOFILE
+<?php
+
+/**
+ * This file is automatically generated. Use 'bin/celerity map' to rebuild it.
+ * {$generated}
+ */
+return {$content};
+
+EOFILE;
+  }
 
   /**
    * Find binary resources (like PNG and SWF) and return information about
@@ -134,21 +154,20 @@
     CelerityPhysicalResources $resources) {
 
     $binary_map = $resources->findBinaryResources();
-
     $result_map = array();
+
     foreach ($binary_map as $name => $data_hash) {
       $hash = $resources->getCelerityHash($data_hash.$name);
 
       $result_map[$name] = array(
         'hash' => $hash,
-        'uri' => $resources->getResourceURI($hash, $name),
+        'uri'  => $resources->getResourceURI($hash, $name),
       );
     }
 
     return $result_map;
   }
 
-
   /**
    * Find text resources (like JS and CSS) and return information about them.
    *
@@ -161,8 +180,8 @@
     CelerityResourceTransformer $xformer) {
 
     $text_map = $resources->findTextResources();
-
     $result_map = array();
+
     foreach ($text_map as $name => $data_hash) {
       $raw_data = $resources->getResourceData($name);
       $xformed_data = $xformer->transformResource($name, $raw_data);
@@ -189,15 +208,14 @@
     return $result_map;
   }
 
-
   /**
    * Parse the `@provides` and `@requires` symbols out of a text resource, like
    * JS or CSS.
    *
    * @param string Resource name.
    * @param string Resource data.
-   * @return pair<string|null, list<string>|null> The `@provides` symbol and the
-   *    list of `@requires` symbols. If the resource is not part of the
+   * @return pair<string|null, list<string>|null> The `@provides` symbol and
+   *    the list of `@requires` symbols. If the resource is not part of the
    *    dependency graph, both are null.
    */
   private function getProvidesAndRequires($name, $data) {
@@ -227,15 +245,12 @@
 
     if (count($provides) > 1) {
       throw new Exception(
-        pht(
-          'Resource "%s" must @provide at most one Celerity target.',
-          $name));
+        pht('Resource "%s" must @provide at most one Celerity target.', $name));
     }
 
     return array(head($provides), $requires);
   }
 
-
   /**
    * Check for dependency cycles in the resource graph. Raises an exception if
    * a cycle is detected.
@@ -254,9 +269,7 @@
       $cycle = $graph->detectCycles($provides);
       if ($cycle) {
         throw new Exception(
-          pht(
-            'Cycle detected in resource graph: %s',
-            implode(' > ', $cycle)));
+          pht('Cycle detected in resource graph: %s', implode(' > ', $cycle)));
       }
     }
   }
@@ -265,8 +278,8 @@
    * Build package specifications for a given resource source.
    *
    * @param CelerityPhysicalResources Resource source to rebuild.
-   * @param list<string, string> Map of `@provides` to hashes.
-   * @param list<string, string> Map of hashes to resource names.
+   * @param map<string, string> Map of `@provides` to hashes.
+   * @param map<string, string> Map of hashes to resource names.
    * @return map<string, map<string, string>> Package information maps.
    */
   private function rebuildPackages(
@@ -322,6 +335,7 @@
   private function mergeNameMaps(array $maps) {
     $result = array();
     $origin = array();
+
     foreach ($maps as $map) {
       list($map_name, $data) = $map;
       foreach ($data as $name => $hash) {
@@ -345,28 +359,4 @@
     return $result;
   }
 
-  private function log($message) {
-    $console = PhutilConsole::getConsole();
-    $console->writeErr("%s\n", $message);
-  }
-
-  private function formatMapContent(array $data) {
-    $content = var_export($data, true);
-    $content = preg_replace('/\s+$/m', '', $content);
-    $content = preg_replace('/array \(/', 'array(', $content);
-
-    $generated = '@'.'generated';
-    return <<<EOFILE
-<?php
-
-/**
- * This file is automatically generated. Use 'bin/celerity map' to rebuild it.
- * {$generated}
- */
-return {$content};
-
-EOFILE;
-  }
-
-
 }
diff --git a/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php b/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php
--- a/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php
+++ b/src/infrastructure/celerity/management/CelerityManagementMapWorkflow.php
@@ -42,331 +42,15 @@
         $resources->getName(),
         get_class($resources)));
 
-    $binary_map = $this->rebuildBinaryResources($resources);
-
-    $this->log(
-      pht(
-        'Found %d binary resources.',
-        new PhutilNumber(count($binary_map))));
-
-    $xformer = id(new CelerityResourceTransformer())
-      ->setMinify(false)
-      ->setRawURIMap(ipull($binary_map, 'uri'));
-
-    $text_map = $this->rebuildTextResources($resources, $xformer);
-
-    $this->log(
-      pht(
-        'Found %d text resources.',
-        new PhutilNumber(count($text_map))));
-
-    $resource_graph = array();
-    $requires_map = array();
-    $symbol_map = array();
-    foreach ($text_map as $name => $info) {
-      if (isset($info['provides'])) {
-        $symbol_map[$info['provides']] = $info['hash'];
-
-        // We only need to check for cycles and add this to the requires map
-        // if it actually requires anything.
-        if (!empty($info['requires'])) {
-          $resource_graph[$info['provides']] = $info['requires'];
-          $requires_map[$info['hash']] = $info['requires'];
-        }
-      }
-    }
-
-    $this->detectGraphCycles($resource_graph);
-    $name_map = ipull($binary_map, 'hash') + ipull($text_map, 'hash');
-    $hash_map = array_flip($name_map);
-
-    $package_map = $this->rebuildPackages(
-      $resources,
-      $symbol_map,
-      $hash_map);
-
-    $this->log(
-      pht(
-        'Found %d packages.',
-        new PhutilNumber(count($package_map))));
-
-    $component_map = array();
-    foreach ($package_map as $package_name => $package_info) {
-      foreach ($package_info['symbols'] as $symbol) {
-        $component_map[$symbol] = $package_name;
-      }
-    }
-
-    $name_map = $this->mergeNameMaps(
-      array(
-        array(pht('Binary'), ipull($binary_map, 'hash')),
-        array(pht('Text'), ipull($text_map, 'hash')),
-        array(pht('Package'), ipull($package_map, 'hash')),
-      ));
-    $package_map = ipull($package_map, 'symbols');
-
-    ksort($name_map);
-    ksort($symbol_map);
-    ksort($requires_map);
-    ksort($package_map);
-
-    $map_content = $this->formatMapContent(array(
-      'names' => $name_map,
-      'symbols' => $symbol_map,
-      'requires' => $requires_map,
-      'packages' => $package_map,
-    ));
-
-    $map_path = $resources->getPathToMap();
-    $this->log(pht('Writing map "%s".', Filesystem::readablePath($map_path)));
-    Filesystem::writeFile($map_path, $map_content);
-  }
-
-
-  /**
-   * Find binary resources (like PNG and SWF) and return information about
-   * them.
-   *
-   * @param CelerityPhysicalResources Resource map to find binary resources for.
-   * @return map<string, map<string, string>> Resource information map.
-   */
-  private function rebuildBinaryResources(
-    CelerityPhysicalResources $resources) {
-
-    $binary_map = $resources->findBinaryResources();
-
-    $result_map = array();
-    foreach ($binary_map as $name => $data_hash) {
-      $hash = $resources->getCelerityHash($data_hash.$name);
-
-      $result_map[$name] = array(
-        'hash' => $hash,
-        'uri' => $resources->getResourceURI($hash, $name),
-      );
-    }
-
-    return $result_map;
-  }
-
-
-  /**
-   * Find text resources (like JS and CSS) and return information about them.
-   *
-   * @param CelerityPhysicalResources Resource map to find text resources for.
-   * @param CelerityResourceTransformer Configured resource transformer.
-   * @return map<string, map<string, string>> Resource information map.
-   */
-  private function rebuildTextResources(
-    CelerityPhysicalResources $resources,
-    CelerityResourceTransformer $xformer) {
-
-    $text_map = $resources->findTextResources();
-
-    $result_map = array();
-    foreach ($text_map as $name => $data_hash) {
-      $raw_data = $resources->getResourceData($name);
-      $xformed_data = $xformer->transformResource($name, $raw_data);
-
-      $data_hash = $resources->getCelerityHash($xformed_data);
-      $hash = $resources->getCelerityHash($data_hash.$name);
-
-      list($provides, $requires) = $this->getProvidesAndRequires(
-        $name,
-        $raw_data);
-
-      $result_map[$name] = array(
-        'hash' => $hash,
-      );
-
-      if ($provides !== null) {
-        $result_map[$name] += array(
-          'provides' => $provides,
-          'requires' => $requires,
-        );
-      }
-    }
-
-    return $result_map;
+    id(new CelerityResourceMapGenerator($resources))
+      ->setDebug(true)
+      ->generate()
+      ->write();
   }
 
-
-  /**
-   * Parse the `@provides` and `@requires` symbols out of a text resource, like
-   * JS or CSS.
-   *
-   * @param string Resource name.
-   * @param string Resource data.
-   * @return pair<string|null, list<string>|null> The `@provides` symbol and the
-   *    list of `@requires` symbols. If the resource is not part of the
-   *    dependency graph, both are null.
-   */
-  private function getProvidesAndRequires($name, $data) {
-    $parser = new PhutilDocblockParser();
-
-    $matches = array();
-    $ok = preg_match('@/[*][*].*?[*]/@s', $data, $matches);
-    if (!$ok) {
-      throw new Exception(
-        pht(
-          'Resource "%s" does not have a header doc comment. Encode '.
-          'dependency data in a header docblock.',
-          $name));
-    }
-
-    list($description, $metadata) = $parser->parse($matches[0]);
-
-    $provides = preg_split('/\s+/', trim(idx($metadata, 'provides')));
-    $requires = preg_split('/\s+/', trim(idx($metadata, 'requires')));
-    $provides = array_filter($provides);
-    $requires = array_filter($requires);
-
-    if (!$provides) {
-      // Tests and documentation-only JS is permitted to @provide no targets.
-      return array(null, null);
-    }
-
-    if (count($provides) > 1) {
-      throw new Exception(
-        pht(
-          'Resource "%s" must @provide at most one Celerity target.',
-          $name));
-    }
-
-    return array(head($provides), $requires);
-  }
-
-
-  /**
-   * Check for dependency cycles in the resource graph. Raises an exception if
-   * a cycle is detected.
-   *
-   * @param map<string, list<string>> Map of `@provides` symbols to their
-   *                                  `@requires` symbols.
-   * @return void
-   */
-  private function detectGraphCycles(array $nodes) {
-    $graph = id(new CelerityResourceGraph())
-      ->addNodes($nodes)
-      ->setResourceGraph($nodes)
-      ->loadGraph();
-
-    foreach ($nodes as $provides => $requires) {
-      $cycle = $graph->detectCycles($provides);
-      if ($cycle) {
-        throw new Exception(
-          pht(
-            'Cycle detected in resource graph: %s',
-            implode(' > ', $cycle)));
-      }
-    }
-  }
-
-  /**
-   * Build package specifications for a given resource source.
-   *
-   * @param CelerityPhysicalResources Resource source to rebuild.
-   * @param list<string, string> Map of `@provides` to hashes.
-   * @param list<string, string> Map of hashes to resource names.
-   * @return map<string, map<string, string>> Package information maps.
-   */
-  private function rebuildPackages(
-    CelerityPhysicalResources $resources,
-    array $symbol_map,
-    array $reverse_map) {
-
-    $package_map = array();
-
-    $package_spec = $resources->getResourcePackages();
-    foreach ($package_spec as $package_name => $package_symbols) {
-      $type = null;
-      $hashes = array();
-      foreach ($package_symbols as $symbol) {
-        $symbol_hash = idx($symbol_map, $symbol);
-        if ($symbol_hash === null) {
-          throw new Exception(
-            pht(
-              'Package specification for "%s" includes "%s", but that symbol '.
-              'is not @provided by any resource.',
-              $package_name,
-              $symbol));
-        }
-
-        $resource_name = $reverse_map[$symbol_hash];
-        $resource_type = $resources->getResourceType($resource_name);
-        if ($type === null) {
-          $type = $resource_type;
-        } else if ($type !== $resource_type) {
-          throw new Exception(
-            pht(
-              'Package specification for "%s" includes resources of multiple '.
-              'types (%s, %s). Each package may only contain one type of '.
-              'resource.',
-              $package_name,
-              $type,
-              $resource_type));
-        }
-
-        $hashes[] = $symbol.':'.$symbol_hash;
-      }
-
-      $hash = $resources->getCelerityHash(implode("\n", $hashes));
-      $package_map[$package_name] = array(
-        'hash' => $hash,
-        'symbols' => $package_symbols,
-      );
-    }
-
-    return $package_map;
-  }
-
-  private function mergeNameMaps(array $maps) {
-    $result = array();
-    $origin = array();
-    foreach ($maps as $map) {
-      list($map_name, $data) = $map;
-      foreach ($data as $name => $hash) {
-        if (empty($result[$name])) {
-          $result[$name] = $hash;
-          $origin[$name] = $map_name;
-        } else {
-          $old = $origin[$name];
-          $new = $map_name;
-          throw new Exception(
-            pht(
-              'Resource source defines two resources with the same name, '.
-              '"%s". One is defined in the "%s" map; the other in the "%s" '.
-              'map. Each resource must have a unique name.',
-              $name,
-              $old,
-              $new));
-        }
-      }
-    }
-    return $result;
-  }
-
-  private function log($message) {
+  protected function log($message) {
     $console = PhutilConsole::getConsole();
     $console->writeErr("%s\n", $message);
   }
 
-  private function formatMapContent(array $data) {
-    $content = var_export($data, true);
-    $content = preg_replace('/\s+$/m', '', $content);
-    $content = preg_replace('/array \(/', 'array(', $content);
-
-    $generated = '@'.'generated';
-    return <<<EOFILE
-<?php
-
-/**
- * This file is automatically generated. Use 'bin/celerity map' to rebuild it.
- * {$generated}
- */
-return {$content};
-
-EOFILE;
-  }
-
-
 }