Ctrl K
ComunidadGuías…

lib/walk-tree.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * walk-tree.mjs — the only hand-rolled directory recursion in this repository.
 *
 * ## Why one walker
 *
 * Every recursive walk in this repo has to answer the same question before it
 * descends: *is this child a checkout of its own?* A linked worktree or a
 * nested clone is a complete second copy of this repository, at whatever commit
 * it happens to sit on, and a gate that walks into one grades source the branch
 * under test does not contain — passing or failing on the age of somebody
 * else's working tree (#3499, #3762).
 *
 * With fourteen hand-rolled recursions that question had to be answered
 * fourteen times, and a static test tried to prove it had been by reading the
 * source of each one. Over four review rounds that test found and closed
 * *eleven distinct bypasses*: a guard on the directory being read rather than
 * the one about to be entered; a guard on an unrelated path; `const full = dir`
 * (a parent wearing a child's name); a result computed and dropped; a guard
 * placed after the descent; a guard on the second of two descents; the right
 * argument in the wrong branch. Each fix was real, and round twelve would very
 * likely have found something too — because `guardsEveryDescent()` was asking a
 * **dataflow** question with **text matching**, and an approximation of a
 * dataflow question has no fixed point (#3818).
 *
 * So the guard stops being a judgement about fourteen recursions and becomes a
 * property of the one that remains. There is a single place to get it wrong, a
 * single behavioural test that proves it is not got wrong
 * (`tests/walk-tree.test.mjs`), and the static rule left over asks about
 * *presence* — "is there another recursion here?" — which is decidable, rather
 * than about semantics, which is not.
 *
 * ## Why the exemption lives at the call site
 *
 * Some walks must NOT skip a marked directory: `plugins/_lock.mjs` hashes a
 * third-party plugin tree for integrity, and a directory that could opt out of
 * the hash by planting a `.git` marker is a place to hide code. Those callers
 * pass `allowNestedCheckouts: true` — declared next to the reason it exists,
 * rather than in an `EXEMPT` map in a test file that needs its own assertion to
 * keep from going stale.
 *
 * ## What does NOT belong here
 *
 * Keep the options object small or it becomes a config blob with a walker
 * attached. Most callers already post-filter their results and should keep
 * doing so — there is deliberately no `match`, no `extensions`, no `depth`
 * limit. A walk whose requirements would distort this signature (`intake.mjs`'s
 * realpath dedupe and alias resolution; `plugins/_lock.mjs`'s refusal to hash
 * a non-regular file) stays hand-rolled with a reasoned exemption in the gate,
 * which is a better outcome than a shared function nobody can read.
 */

import { existsSync, readdirSync, realpathSync, statSync } from 'fs';
import { join } from 'path';
import { isScratchDir } from './scratch-dirs.mjs';

/**
 * Is `dir` a checkout of its own, rather than a subdirectory of this one?
 *
 * Any `.git` entry counts, of either type. A `.git` FILE is a linked worktree
 * or a submodule; a `.git` DIRECTORY below the root is a nested independent
 * clone. All three are somebody else's source tree that happens to sit inside
 * this one, and none of them is a file this repository's gates should grade.
 *
 * Excluding the *name* `.git` is not enough on its own, which is what made
 * #3499 hard to see: that catches the whole of git's storage in a normal clone
 * and none of it in a linked worktree, so the one exclusion meant to keep git
 * out of the walk slid straight past a second copy of the repository. The
 * consequences then differed per consumer — the syntax gates silently checked
 * ~2x the files (1097 reported in a 576-file checkout);
 * `tests/local-today-gates.test.mjs` failed outright, naming *correct* files as
 * violations because the stale copy predated the convention;
 * `tests/main-guard-convention.test.mjs` passed, because the stale copy
 * happened to satisfy that convention.
 *
 * Detecting the marker rather than blanket-ignoring `.claude/worktrees/`
 * (Claude Code's default location, which is how this was found) is both
 * narrower and broader: it holds for a worktree placed anywhere, and it costs
 * nothing for a checkout that has none.
 *
 * NOT applied to a walk root — see `walkTree`.
 *
 * @param {string} dir - Absolute path to a directory.
 * @returns {boolean} True if `dir` carries its own git marker.
 */
export function isNestedCheckout(dir) {
  return existsSync(join(dir, '.git'));
}

/**
 * Walk `root` depth-first and visit every regular file below it.
 *
 * **The nested-checkout guard is not optional and not the caller's job.** Every
 * directory this function is about to descend into is tested first, and a
 * marked one is not entered. That is the whole reason this module exists; see
 * the header.
 *
 * The walk root itself is deliberately never tested. The root's own `.git` is
 * what makes it a repository, and running a gate from inside a linked worktree
 * (where `.git` is a file too) must check that worktree's source rather than
 * skip all of it and report a passing scan of nothing.
 *
 * @param {string} root - Absolute path to walk. Must exist; see `onError`.
 * @param {object} [options]
 * @param {(entry: import('fs').Dirent, dir: string, depth: number) => boolean} [options.skip]
 *   Per-entry veto, called for files and directories alike before anything else
 *   is done with them. `depth` is 0 for entries directly inside `root`, so a
 *   caller can express a root-only exclusion. Return true to ignore the entry.
 * @param {(abs: string, entry: import('fs').Dirent) => void} [options.onFile]
 *   Called for each regular file. Optional — the return value carries the same
 *   paths.
 * @param {(abs: string, entry: import('fs').Dirent) => void} [options.onDir]
 *   Called for each directory that survived `skip` and the nested-checkout
 *   guard, before descending into it. Exists for walks that must materialise
 *   directories (a tree copy), including empty ones.
 * @param {'skip'|'follow'|'reject'} [options.links='skip']
 *   Symlinked entries: ignored, resolved with `statSync` (a broken link is
 *   ignored), or fatal. The default is `skip` because a symlinked directory can
 *   point outside the checkout or back into it, and neither should make a walk
 *   recurse unpredictably.
 * @param {'ignore'|'throw'} [options.onError='ignore']
 *   What to do when a directory BELOW the root cannot be read. `ignore` treats
 *   it as a race — readdir listed it and it was gone by the time we recursed,
 *   which a concurrent `git checkout` or a test tearing down a temp tree will
 *   do — and aborting a whole run over a directory that has ceased to be helps
 *   nobody. A missing or unreadable ROOT always throws whatever the setting:
 *   that is not a race, it is a bad argument, and swallowing it would return an
 *   empty list, so a gate would report "0 files" and pass having checked
 *   nothing. That failure mode is strictly worse than the bug it replaced
 *   (#3419), so it is not reachable from here. A caller that legitimately
 *   tolerates a missing root (`tests/helpers.mjs`'s `walkFiles`) says so in its
 *   own contract with an `existsSync` check.
 * @param {boolean} [options.sort=true]
 *   Sort each directory's entries by name before visiting them. On by default
 *   because readdir order is filesystem order, which varies by platform, and
 *   every caller here either reports results in iteration order (where a
 *   run-to-run reordering is diff noise) or hands them to a test runner (where
 *   it is a reordering of the run itself).
 * @param {boolean} [options.allowNestedCheckouts=false]
 *   Descend into marked directories anyway — nested checkouts AND directories
 *   named like a `test-all.mjs` scratch copy. For walks anchored at a tree this
 *   repository does not own, where skipping either would let it hide code from
 *   an integrity hash or a deny-list scan. Declare it at the call site with the
 *   reason.
 * @returns {string[]} Absolute paths of every file visited, in traversal order.
 */
export function walkTree(root, {
  skip = null,
  onFile = null,
  onDir = null,
  links = 'skip',
  onError = 'ignore',
  sort = true,
  allowNestedCheckouts = false,
} = {}) {
  if (links !== 'skip' && links !== 'follow' && links !== 'reject') {
    throw new TypeError(`walkTree: links must be 'skip', 'follow' or 'reject' (got ${JSON.stringify(links)})`);
  }
  if (onError !== 'ignore' && onError !== 'throw') {
    throw new TypeError(`walkTree: onError must be 'ignore' or 'throw' (got ${JSON.stringify(onError)})`);
  }

  const files = [];
  // Real paths of the directories between the root and the one being read.
  // Populated only when links are followed; see the cycle guard below.
  const chain = new Set();
  if (links === 'follow') {
    try { chain.add(realpathSync(root)); } catch { /* a missing root throws in walk(), with its own errno */ }
  }

  const walk = (dir, depth) => {
    let entries;
    try {
      entries = readdirSync(dir, { withFileTypes: true });
    } catch (err) {
      if (depth > 0 && onError === 'ignore' && err?.code === 'ENOENT') return;
      throw err;
    }
    if (sort) entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));

    for (const entry of entries) {
      if (skip && skip(entry, dir, depth)) continue;
      const abs = join(dir, entry.name);

      let isDir = entry.isDirectory();
      let isFile = entry.isFile();
      if (entry.isSymbolicLink()) {
        if (links === 'skip') continue;
        if (links === 'reject') throw new Error(`walkTree: refusing to follow symlink: ${abs}`);
        try {
          const st = statSync(abs);
          isDir = st.isDirectory();
          isFile = st.isFile();
        } catch {
          continue; // broken link
        }
      }

      if (isDir) {
        // THE guard. One recursion in the repository means one place this can
        // be got wrong, and one behavioural test that proves it is not.
        //
        // Two kinds of "a second copy of this repository that is not its
        // source", told apart by how they mark themselves. A nested checkout
        // carries `.git`. A scratch copy left by an interrupted `test-all.mjs`
        // carries no `.git` at all — it is copied without one — so only its
        // name gives it away, and only lib/scratch-dirs.mjs may spell that name
        // (#3940). Directories only, which is why this sits inside `isDir`: the
        // prefix can name a FILE too, and dropping `.tmp-script-test-x.mjs` from
        // a gate's file list would be the "gate covering a file set nobody
        // chose" failure lib/mjs-files.mjs exists to remove. Any depth, because
        // a copy that wrapped an earlier leftover nests one inside the other.
        //
        // `allowNestedCheckouts` turns off both halves together. The walks that
        // pass it scan a third-party tree for integrity or a deny-list, and to
        // them a directory named like a scratch copy is exactly as much a place
        // to hide code as one carrying a `.git` marker.
        if (!allowNestedCheckouts && (isNestedCheckout(abs) || isScratchDir(entry.name))) continue;

        // Cycle guard, and only under `links: 'follow'` — with links skipped
        // there is no way to re-enter a directory, so the default path pays
        // nothing. A link pointing at one of its own ancestors (docs/current ->
        // docs/) passes statSync as an ordinary directory and would otherwise
        // be descended until the path length or the stack gives out. The two
        // callers that follow links both walk trees a user can shape:
        // `test-all.mjs`'s repo copy and `seed-fixture.mjs`'s fixture manifest.
        //
        // Scoped to the ANCESTOR CHAIN, not a global visited set: two sibling
        // links onto the same real directory are not a cycle, and collapsing
        // them would silently drop the second copy from a tree copy that is
        // supposed to mirror what is there.
        if (links === 'follow') {
          let real;
          try { real = realpathSync(abs); } catch { continue; }
          if (chain.has(real)) continue;
          chain.add(real);
          if (onDir) onDir(abs, entry);
          walk(abs, depth + 1);
          chain.delete(real);
          continue;
        }

        if (onDir) onDir(abs, entry);
        walk(abs, depth + 1);
      } else if (isFile) {
        files.push(abs);
        if (onFile) onFile(abs, entry);
      }
    }
  };

  walk(root, 0);
  return files;
}

/**
 * Every file under `root`, as one call — the replacement for
 * `readdirSync(dir, { recursive: true })` and `globSync`.
 *
 * Those two are banned outside this module for the same reason the hand-rolled
 * recursions are: neither consults `isNestedCheckout`, so both walk straight
 * into a nested checkout and hand the caller a list of files from a tree the
 * caller does not own. Their callers then have to remember to filter the
 * results, which is the same "did you remember the guard?" question this module
 * exists to stop asking.
 *
 * @param {string} root - Absolute path to walk.
 * @param {object} [options]
 * @param {RegExp} [options.match] - Tested against each absolute path; unmatched files are dropped.
 * @param {(entry: import('fs').Dirent, dir: string, depth: number) => boolean} [options.skip] - As `walkTree`.
 * @param {'skip'|'follow'|'reject'} [options.links='skip'] - As `walkTree`.
 * @param {boolean} [options.allowNestedCheckouts=false] - As `walkTree`.
 * @returns {string[]} Absolute paths, in traversal order.
 */
export function listTree(root, { match = null, skip = null, links = 'skip', allowNestedCheckouts = false } = {}) {
  const files = walkTree(root, { skip, links, allowNestedCheckouts });
  return match ? files.filter((f) => match.test(f)) : files;
}