Source: lib/bundler/treeshake.js

/**
 * @file treeshake.js
 * @description Decides which modules an application actually needs.
 *
 * ## What was impossible before, and why
 *
 * The build prepended `dist/runtime.min.js` — one pre-bundled blob — to every
 * application. Nothing about that arrangement admits the question "does this
 * application use the trace recorder?", because by the time the compiler ran,
 * the recorder had already been fused into a single artifact. Shaking is not a
 * feature that was missing; it was unaskable.
 *
 * With the runtime consumed as modules the question is answerable, and this
 * file answers it.
 *
 * ## The rule, and its one deliberate departure from the specification
 *
 * Two kinds of edge are treated differently, which is the whole mechanism:
 *
 * - **A plain `import` always executes its target.** That is what ES modules
 *   do, and the runtime depends on it: `reactive/proxyHandler.js` calls
 *   `setPathResolver(getPropertyPath)` at its top level, and that call has to
 *   happen. No analysis here will ever drop a module that something imports.
 *
 * - **A re-export edge is followed only for the names that are needed.** A
 *   barrel is a routing table, and `export { startRecording } from
 *   './trace/recorder.js'` in a barrel nobody asks `startRecording` of is a
 *   route to nowhere. Strictly, the specification says that re-export executes
 *   `recorder.js`; every bundler departs from it here, because otherwise no
 *   barrel is ever shakeable and `avenx-core/runtime` is a barrel.
 *
 * The departure is bounded rather than blanket. A re-export target is still
 * kept when it has a top-level side effect of its own — an expression statement
 * rather than a declaration — or when its package declares itself effectful
 * through `sideEffects` in `package.json`. Exactly one module in the Avenx
 * runtime has such a statement, and it is reached by a plain import anyway.
 *
 * ## What this does not do
 *
 * It does not remove unused *declarations inside* a module that is kept.
 * Statement-level elimination needs a real identifier analysis, and a
 * bundler that guesses at that miscompiles code rather than shrinking it.
 * The honest consequence is stated in the build's own reporting: what
 * shaking removes here is whole modules, and the fixed cost of a module that
 * something imports is its whole source.
 * @module lib/bundler/treeshake
 */

import fs from 'fs';
import path from 'path';

/**
 * The wildcard standing for "every export of this module is needed".
 * @type {string}
 */
const ALL = '*';

/**
 * Whether a module runs code at its top level beyond declaring things.
 *
 * An expression statement at module scope is a side effect: it happens when the
 * module is evaluated and nothing else will make it happen. A declaration is
 * not, even when its initialiser calls something — that is the standard
 * assumption every bundler makes, and abandoning it would keep every module
 * that ever writes `const x = new Thing()`.
 * @param {object} module - A graph module.
 * @returns {boolean} True when the module must run if it is reached at all.
 */
export function hasTopLevelEffects(module) {
  if (module.format !== 'esm') {
    // A CommonJS module's body is an assignment to `module.exports` and
    // whatever else it likes. Nothing here can tell those apart.
    return true;
  }
  return module.record.statements.some(
    (statement) => statement.kind === 'statement' && statement.declares.length === 0,
  );
}

/**
 * Reads the `sideEffects` declaration of the package a module belongs to.
 *
 * `"sideEffects": false` is the convention a package uses to say its modules
 * can be dropped when unused. It is honoured for third-party packages, where
 * this analysis has no other way to know.
 * @param {string} file - Absolute module path.
 * @param {Map<string, boolean|null>} cache - Per-directory answers.
 * @returns {boolean|null} False when the package declares itself pure, true
 *   when it declares effects, null when it says nothing.
 */
function packageSideEffects(file, cache) {
  let dir = path.dirname(file);
  for (;;) {
    if (cache.has(dir)) {
      return cache.get(dir);
    }
    const manifestPath = path.join(dir, 'package.json');
    if (fs.existsSync(manifestPath)) {
      let answer = null;
      try {
        const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
        if (manifest.sideEffects === false) answer = false;
        else if (manifest.sideEffects === true) answer = true;
      } catch {
        answer = null;
      }
      cache.set(dir, answer);
      return answer;
    }
    const parent = path.dirname(dir);
    if (parent === dir) {
      cache.set(dir, null);
      return null;
    }
    dir = parent;
  }
}

/**
 * Works out which modules the bundle must contain.
 * @param {object} options - Shake options.
 * @param {object} options.graph - The linked module graph.
 * @param {string[]} options.order - Modules in emission order.
 * @param {Map<string, string[]>} [options.entryNeeds] - Export names an entry's
 *   consumer requires, keyed by entry id. `['*']` means the whole namespace.
 * @returns {Set<string>} Module ids to emit.
 */
export function shake({ graph, order, entryNeeds = new Map() }) {
  /** @type {Map<string, Set<string>>} */
  const needed = new Map();
  /** @type {Set<string>} */
  const executed = new Set();
  const effectsCache = new Map();

  const need = (id, name) => {
    if (!needed.has(id)) needed.set(id, new Set());
    const set = needed.get(id);
    if (set.has(ALL) || set.has(name)) return false;
    set.add(name);
    return true;
  };

  const queue = [];
  const execute = (id) => {
    if (executed.has(id)) return;
    executed.add(id);
    queue.push(id);
  };

  for (const entry of graph.entries) {
    execute(entry);
    for (const name of entryNeeds.get(entry) || []) {
      need(entry, name);
    }
  }

  /**
   * Whether a module must be kept even though none of its exports are wanted.
   * @param {object} module - The graph module.
   * @returns {boolean} True when dropping it would change behaviour.
   */
  const mustKeep = (module) => {
    const declared = module.external ? packageSideEffects(module.id, effectsCache) : null;
    if (declared === false) return false;
    if (declared === true) return true;
    return hasTopLevelEffects(module);
  };

  while (queue.length > 0) {
    const id = queue.shift();
    const module = graph.modules.get(id);
    if (!module) continue;

    const wanted = needed.get(id) || new Set();

    if (module.format === 'cjs') {
      // Nothing about a CommonJS module's exports is static, so every module it
      // requires is needed.
      for (const target of module.resolved.values()) {
        execute(target);
      }
      continue;
    }

    // A dynamic import needs the whole namespace: nothing here can say which
    // member the awaiting code will read.
    for (const entry of module.record.dynamicImports) {
      const target = entry.specifier === null ? null : module.resolved.get(entry.specifier);
      if (!target) continue;
      const walked = executed.has(target);
      execute(target);
      if (need(target, ALL) && walked) queue.push(target);
    }

    // A plain import executes its target, exactly as the language says.
    //
    // Wanting a name from a module that has *already* been walked has to put it
    // back on the queue. A module resolves its own re-exports only while it is
    // being walked, so a name first wanted after that point would never be
    // followed to the module that actually defines it -- the re-export would be
    // dropped and the importer would destructure `undefined` at run time. The
    // re-export loop below has always re-queued for this reason; these two did
    // not, and the difference only showed once a generated component module --
    // which is linked after the runtime it imports from -- became the first
    // consumer of a re-exported name.
    for (const entry of module.record.imports) {
      const target = module.resolved.get(entry.specifier);
      if (!target) continue;
      const walked = executed.has(target);
      execute(target);

      // `need` is called for its effect on every branch, so the accumulator is
      // written this way round: `added || need(...)` would stop calling it.
      let added = false;
      if (entry.namespace) added = need(target, ALL) || added;
      if (entry.defaultLocal) added = need(target, 'default') || added;
      for (const binding of entry.bindings) {
        added = need(target, binding.imported) || added;
      }

      if (added && walked) queue.push(target);
    }

    // A re-export is followed only for the names something asked for.
    for (const entry of module.record.reExports) {
      const target = module.resolved.get(entry.specifier);
      if (!target) continue;
      const targetModule = graph.modules.get(target);
      const asked = wanted.has(ALL) || wanted.has(entry.exported);
      if (!asked && targetModule && !mustKeep(targetModule)) continue;
      execute(target);
      if (asked) {
        if (need(target, entry.imported) && executed.has(target)) queue.push(target);
      }
    }

    for (const entry of module.record.starReExports) {
      const target = module.resolved.get(entry.specifier);
      if (!target) continue;
      const targetModule = graph.modules.get(target);
      if (!targetModule) continue;

      if (wanted.has(ALL)) {
        execute(target);
        need(target, ALL);
        queue.push(target);
        continue;
      }

      const shared = [...wanted].filter((name) => targetModule.exportNames.has(name));
      if (shared.length === 0 && !mustKeep(targetModule)) continue;
      execute(target);
      for (const name of shared) {
        if (need(target, name)) queue.push(target);
      }
    }
  }

  // Preserve emission order rather than discovery order: the emitter relies on
  // dependencies coming first, and that property belongs to the topological
  // sort, not to this traversal.
  return new Set(order.filter((id) => executed.has(id)));
}