Files
msd-core/tests/helpers/overlay-repo.cjs
Tom Boucher 1178c5f995 test(#3108): make the overlay ENOENT tolerance and hooks/dist readiness check honest (#3772)
* test(#3108): failing-first suite for the overlay vanish-retry and hooks/dist staleness

RED by construction, and deliberately narrower than the issue.

#3108 reports a bare `ENOENT ... link '/work/hooks/dist/gsd-session-state.sh'`
and attributes it to hooks/dist never having been built. That mechanism cannot
produce that error: buildOverlayRepo enumerates with readdirSync and links what
it enumerated, so a directory that never existed yields no names and no link is
ever attempted. The error requires the file to have existed at readdir and
vanished before the link -- which is the atomic-replace race placeVanishableLeaf
was written for in #3285, three days AFTER this issue was filed.

What is still genuinely broken, and what these tests bind to:

placeVanishableLeaf's retry is unguarded. On ENOENT it re-checks existsSync and
calls attempt() once more, bare. A second atomic replace inside that window
throws an unhandled ENOENT of exactly the reported shape. Rows 1/2/3/5/6/7 fence
the surrounding contract -- most already pass, which is the point: they are what
stops the fix from widening into "swallow every error". Row 6 in particular
covers a non-ENOENT on the RETRY, the exact path the new code will live on.

ensureHooksDist's staleness predicate is extension-blind. It rebuilds only when
hooks/dist is absent or holds zero .js files, while build-hooks.js also ships
.sh -- including gsd-session-state.sh, the very file in the report. A dist with
.js present and every .sh missing reads as populated and the rebuild is skipped.
Rows 10 and 11 are mirrored on purpose: asserting only the .sh direction would
permit swapping one extension heuristic for another, so both directions force
the predicate to be about the expected set (HOOKS_TO_COPY, which build-hooks.js
already exports) rather than about counting an extension.

Rows 12 and 13 are cost guards. ensureHooksDist runs per suite and its rebuild
is a real subprocess, so a predicate that over-triggers turns a correctness fix
into a throughput regression nobody attributes to it; and hooks/dist legitimately
carries files the expected list does not name, so an exact-set match would
rebuild forever.

The warning-text row is t.skip()'d rather than faked: the only ways to assert it
were a source-grep (banned by local/no-source-grep) or a full overlay build, and
a test that cannot be written honestly is better skipped visibly than written
vacuously.

Filename note: this started as fix-3108-*.test.cjs and tripped
lint-regression-test-names, which bans new fix/bug/issue-NNNN files, then as
install-overlay-helpers.test.cjs and tripped lint-test-file-count, whose `install`
bucket is already at its limit. Module-named under the overlay bucket satisfies
both. The allowlists were left untouched -- both are empty, so nothing here is
grandfathered and adding an entry would have been the wrong instinct.

* fix(#3108): guard the overlay retry and make the hooks/dist check see .sh

Two holes, both reachable from the failure #3108 reports, neither of them the
cause it names.

placeVanishableLeaf's retry was bare. On ENOENT it re-checked existsSync and
called attempt() once more with no catch, so a second atomic replace landing
inside that window threw an unhandled ENOENT -- exactly the reported
`ENOENT ... link '/work/hooks/dist/gsd-session-state.sh'`. Two vanishes inside
the window means the same thing one does: the path is going away and is not part
of the snapshot. It now returns false and skips the leaf, reaching the conclusion
the single-vanish case already reached.

Still ONE retry. No loop, no backoff, no sleep -- the existing comment argues
that a timing-based wait here would be the flake rather than the fix, and that
reasoning did not change. Non-ENOENT still propagates from either attempt, which
is the invariant a careless widening would eat; the suite pins it on the retry
path specifically, because a fix that guarded only the first attempt would look
right and be wrong.

ensureHooksDist could not see the file class that caused the report. It rebuilt
only when hooks/dist was absent or held zero .js files, while build-hooks.js also
ships .sh -- including gsd-session-state.sh itself. A dist with .js present and
every .sh missing read as populated and the rebuild was skipped. The predicate is
now membership against build-hooks.js's own exported HOOKS_TO_COPY, so it asks
"is everything expected present" instead of counting an extension, and it cannot
be blind to a file class again.

It is extracted as isHooksDistStale(dir) with ensureHooksDist calling it, so
there is one predicate rather than two that can drift. Extra unexpected entries
are explicitly not stale -- hooks/dist legitimately accumulates subdirectory and
hooks/lib output, and an exact-set match would rebuild forever. One readdirSync
into a Set, no per-entry existsSync, no stat: it runs per suite and its rebuild
is a real subprocess, so an over-triggering predicate would turn this into a
throughput regression nobody would attribute to it.

The skipped-leaf warning now names `npm run build:hooks`. It already named the
cause; a reader still had to know what produces that directory.

Deliberately NOT done: nothing here makes an absent hooks/dist fail. Absence is a
legitimate package shape that bin/install.js:11191 treats as "nothing to verify",
and six of the seven install suites never read the directory at all.

* fix(#3108): count hooks/dist subdirectories, and never throw out of the predicate

Two gaps found reviewing the predicate I had just written.

It ignored HOOKS_SUBDIRS_TO_COPY. That is ["lib"], and hooks/dist/lib carries
gsd-graphify-rebuild.sh, so a dist with all 27 top-level files but no lib/ read
as populated. That is precisely the blindness the .js-count heuristic had, one
level down: a whole file class invisible to the check. Fixing the extension case
and leaving the subdirectory case would have been half a fix, and the half left
behind is the one nobody would look at again.

Subdir names are bare (no slashes), so they slot into the same top-level readdir
Set — no second readdirSync, no stat. Whether lib is really a directory is not
checked; that would cost a stat per entry and buys nothing, since the build owns
that.

It could also throw. existsSync passing does not make readdirSync safe: the path
may be a regular file (ENOTDIR), unreadable (EACCES), or retired in the race
between the two calls. This helper runs in every install suite's before(), so an
unhandled throw there fails a suite on a condition it cannot act on. Unreadable
is indistinguishable from unusable for this question, and rebuilding is
idempotent, so it now reports stale instead.

Both are the same shape as the original bug and the same shape as each other: a
guard that answers "is this ready" must not have a blind spot or a hard edge,
because every caller treats a false negative as "carry on".

Two existing tests asserted a "complete" dist without lib and had to be corrected
to keep meaning what their names claim, rather than being left passing against a
definition of complete that no longer holds.

* test(#3108): close the review findings, including a half-closed subdir check

An isolated correctness reviewer found no blockers and four real gaps.

The wiring was untested. Every Group-2 test exercised the pure predicate; none
called ensureHooksDist. So restoring the old inline .js-count check INSIDE
ensureHooksDist -- keeping isHooksDistStale exported and correct -- left the
whole suite green, and that wiring is the actual #3108 defect. Two tests now
drive ensureHooksDist itself through the process seam: build invoked exactly once
when stale, never when fresh. The second is the one a permissive revert fails.

Reaching that seam meant requiring process-seam as a module object rather than
destructuring runNode, so a test can replace it in place. That is a testability
affordance in a test helper, not a production change, and it is commented as such
so it does not read as an accident later.

The subdir check was only half closed, and the half left open was the important
one. It required `lib` to be PRESENT in the top-level readdir, never looked
inside -- so an EMPTY dist/lib, missing gsd-graphify-rebuild.sh, still read as
populated. That is precisely the missing-file-class case the subdir check was
added to catch, which made the fix a gesture at the problem rather than a fix.
Each subdir entry must now be a readable, NON-EMPTY directory. A stray regular
file named `lib` throws ENOTDIR into the same try/catch and reads stale too.

Cost stayed honest: one extra readdirSync total (there is exactly one subdir
entry), no stat, no per-expected-file syscall. Probed against the real
hooks/dist -- still reports fresh, so no suite gains a rebuild.

Two nits, both real: the error-code sweep re-tested EACCES already covered
standalone, and the property ignored presentAtFinalAttempt whenever vanishCount
was not 1, making roughly half the 200 runs duplicates. The flag now varies
meaningfully across the whole range and the assertions depend on it.

One reviewer finding was already stale: the subdir and ENOTDIR work was
uncommitted when the reviewer snapshotted the tree, and had landed in a67aefb9c
before the report arrived. Verified rather than assumed.

* fix(#3108): stop the vanish tolerance from swallowing a dest-side ENOENT

A defect this PR introduced, caught by an isolated security reviewer.

linkSync(src, dest) throws ENOENT for the DESTINATION path too, not only for a
vanished source. The widened retry caught that, saw the source still present,
retried, got the same dest-side ENOENT, and returned false -- recording the leaf
as "vanished mid-walk" and printing a warning that tells the reader to run
`npm run build:hooks`. A remedy with nothing to do with the actual cause, an
overlay quietly short a file, and the install under test proceeding against an
incomplete tree.

It also falsified the function's own documented invariant, which says in as many
words: "Returns false only when the path left the source tree entirely." Widening
the tolerance without re-reading the sentence above it is how that happens.

The retry now re-checks existsSync(srcPath) before tolerating: source still
present means the ENOENT was about something else and it propagates untouched.
Chose the existsSync re-check over comparing retryErr.path to srcPath -- err.path
normalization is not guaranteed across platforms, and a path-equality test is a
subtler thing to get wrong later.

The FIRST catch was probed and is already correct: for a dest-side ENOENT the
source is present, so it falls through to the retry rather than returning false.
Left unchanged rather than "fixed" symmetrically.

Two regression pins, deliberately opposed: a dest-side ENOENT with the source
present must THROW, and a genuinely absent source must still return false. The
second exists because the obvious over-correction -- always rethrow on the retry
-- passes the first and silently undoes what this PR set out to fix.

Also closed the skipped placeholder. It claimed no non-flaky seam existed for
asserting the warning text; the reviewer pointed out an injectable `warn` param
is trivial, and they were right. buildOverlayRepo now takes opts.warn defaulting
to console.warn (byte-identical for every existing caller) and the skip is
replaced by real tests: fires with the remedy named on a skipped leaf, silent on
a clean walk. "No seam exists" was a design choice presented as a constraint.

Recorded the sequential-only constraint at the two sites that monkeypatch fs
process-wide: adding { concurrency: true } to this file would cross-contaminate
every other suite in the process. Better written down than rediscovered.

Known limit, disclosed rather than fixed here: a legitimately dropped leaf can
still pass vacuously downstream -- agent-fragments-emission asserts a negative
over filesContaining, and mcp-catalog-parity has only an anti-vacuity floor of
one. That is a pre-existing property of those suites and the tolerance #3285
already chose; this change narrows which drops are possible rather than adding
the completeness assertion those suites lack.

* fix(#3108): discriminate ENOENT by the dest parent, not by re-checking the source

The previous commit's dest-side guard was wrong, and the remote run said so:

  "a leaf that vanishes again during the retry is skipped, not a bare ENOENT"
  Got unwanted exception. Actual message: "ENOENT: no such file or directory"

That test was right and the guard was wrong. It rethrew when existsSync(srcPath)
was still true, on the theory that a present source means the ENOENT was about
the destination. But in the genuine race the source is being atomically REPLACED,
so it is legitimately present again at the re-check while the ENOENT was entirely
source-side. The gate therefore threw on precisely the race #3285 exists to
tolerate -- trading one misclassification for a worse one, since the old bug was
a bare crash and the new one broke the working tolerance.

The security reviewer's alternative discriminator does not work either, and a
probe settles it. Node populates BOTH `path` and `dest` on a link ENOENT, and
`err.path` is the SOURCE in both directions:

  linkSync(existingSrc, missingDir/a.txt) -> ENOENT path=<source> dest=<dest>
  linkSync(missingSrc,  validDest)        -> ENOENT path=<source> dest=<dest>

So the error object cannot tell you which side failed.

What CAN: the dest parent. buildOverlayRepo builds its own dest tree --
place() mkdirSync's recursively into a private mkdtempSync root no other process
touches -- so a missing dest parent is always a bug (Windows MAX_PATH, a
concurrent cleanup, a bad dest), never the replace race. A present dest parent
means the ENOENT was about the source, which is the case we tolerate.

placeVanishableLeaf therefore takes an optional destPath and uses the dest
parent as the sole discriminator when it has one; with no destPath it behaves
exactly as before. linkOrCopyFile and the copy-mode call site both pass it,
because those are the two places that actually know the destination.

The doc comment now records BOTH failed discriminators and why each fails --
existsSync because the source is legitimately replaced mid-race, err.path
because it names the source either way. Those are the two things a future reader
reaches for first, and both look correct until they are not.

The dest-side regression pin was rewritten to drive the real mechanism: a real
temp source and a dest whose parent does not exist, through linkOrCopyFile.
Previously it forced a throw through a present source, which is what encoded the
wrong theory into a test and made it look verified.

---------

Co-authored-by: sim <sim@local>
2026-08-22 23:04:44 -04:00

295 lines
14 KiB
JavaScript

'use strict';
/**
* overlay-repo.cjs — shared "overlay repo" builder for install-spawning test
* suites (extracted from tests/workflow-fragments-emission.install.test.cjs,
* issue #2933, so a second divergent copy is never written — see
* CONTEXT.md's Generative Fix Divergence anti-pattern).
*
* ── The overlay technique ────────────────────────────────────────────────
*
* A test that needs a spawned `bin/install.js` to read a DIFFERENT
* `gsd-core/workflows/execute-phase.md` (or any other repo file) than this
* checkout's real one, without paying to copy the ~400 MB repository (mostly
* node_modules) for every run, calls `buildOverlayRepo` with a map of
* POSIX-relative-path -> replacement content. `buildOverlayRepo` mirrors the
* repo tree with real directories (so `copyWithPathReplacement`'s own
* `entry.isDirectory()` / `entry.isFile()` Dirent checks — which do NOT
* follow symlinks — see the correct type) and HARD-LINKS every unmodified
* leaf file (not symlinks: a symlinked leaf file also fails an `isFile()`
* Dirent check elsewhere in the installer, verified empirically — "Failed
* to install agents: directory is empty" against a symlink-leaf overlay).
* Only `node_modules` and `.git` are symlinked at the top level (install.js
* never walks into either), which is what keeps the overlay build fast.
* Every overlay-spawned installer should run with `--preserve-symlinks
* --preserve-symlinks-main` as a defensive belt: with an all-hardlink leaf
* layout this checkout does not currently NEED symlink-preservation for
* correctness, but the flag is free insurance against a future install.js
* change that resolves a node_modules package by real path.
*
* `buildOverlayRepo` can only REPLACE the content of a real leaf file that
* already exists somewhere under `REPO_ROOT` — it cannot graft in a net-new
* path (a `fileOverrides` key naming a path with no existing file/directory
* ancestor in the real tree is silently never created, since `place()` only
* walks `fs.readdirSync` of the REAL source directory).
*
* ── `opts.mode`: 'link' (default) vs 'copy' ─────────────────────────────
*
* `'link'` (the default, and every pre-existing caller's behavior) hard-links
* every unmodified leaf — cheap, but a `--write` generator run inside the
* overlay does an in-place `writeFileSync` through that hard link, i.e. the
* SAME INODE as this real checkout's own tracked file, silently corrupting
* it. `'copy'` mode instead COPIES every unmodified leaf (`fs.copyFileSync`,
* a real independent inode), so a real `--write` generator — or a full `npm
* run regen:derived` chain — can safely run to completion inside the overlay
* without ever touching `REPO_ROOT`. `node_modules` and `.git` are still
* symlinked at the top level in BOTH modes (unchanged from `'link'` mode):
* `install.js`/`npm`/`tsc` never write into either through the overlay path,
* only read/resolve through them, and symlinking is what keeps even
* `'copy'` mode affordable (`node_modules` alone dwarfs the rest of the
* tree).
*/
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..', '..');
const OVERLAY_SKIP_TOP = new Set(['node_modules', '.git']);
/**
* True when `err` reports that a path was not there.
*
* @param {unknown} err
* @returns {boolean}
*/
function isMissingPath(err) {
return Boolean(err) && typeof err === 'object' && err.code === 'ENOENT';
}
/**
* Run `attempt` against a source path that another process may be replacing
* underneath the walk, and report whether the leaf was actually placed.
*
* `buildOverlayRepo` enumerates names with `readdirSync` and then acts on them,
* which is a TOCTOU window. It is not theoretical: `hooks/dist` is regenerated
* by an ATOMIC REPLACE (`scripts/build-hooks.js` unlinks and renames), so any
* concurrently running test that rebuilds hooks makes a just-listed name vanish
* mid-walk. That took down three runs on three different branches with a bare
* `ENOENT ... link '/work/hooks/dist/...'`.
*
* On ENOENT the source is re-examined ONCE rather than slept on: an atomic
* rename is a single syscall, so by the time the failure surfaces the successor
* is either already in place (retry succeeds) or the path is genuinely gone from
* the tree (nothing to mirror, so the leaf is skipped). No sleep, no spin — a
* timing-based wait here would be the flake this is fixing, not a fix for it.
*
* The retry itself can also lose the race — a second atomic replace landing in
* the same window makes the retry throw ENOENT too (e.g. two concurrent
* `build:hooks` runs). That is still just "the path is vanishing": the same
* conclusion the single-vanish case reaches, so it is likewise treated as
* skipped rather than left to escape as a bare uncaught ENOENT (#3108). There
* is still only ONE retry — a second retry would turn this into the sleep/spin
* loop the comment above already rejects.
*
* Returns false only when the path left the source tree entirely (on the
* first attempt OR the retry); the overlay mirrors the tree, and a file that
* is no longer in it is not part of the snapshot. Every other error — from
* EITHER attempt, non-ENOENT — propagates untouched; that invariant must hold
* for any future widening of this tolerance.
*
* When no `destPath` is supplied, an ENOENT is tolerated as a vanished
* source: on the FIRST attempt's ENOENT, `srcPath` is re-checked with
* `fs.existsSync` to decide whether the retry is even worth attempting (gone
* already -> skip, no retry); if the retry's own attempt ALSO throws ENOENT,
* that is tolerated UNCONDITIONALLY — by the time a second atomic replace has
* landed in the same window there is nothing left to meaningfully re-check,
* and this is deliberately optimistic rather than throwing on the race this
* function exists to tolerate.
*
* Two discriminators that look like they should tell a vanished-source ENOENT
* apart from a dest-side one (a missing DEST parent directory, e.g. a Windows
* MAX_PATH failure or a concurrently-removed dest subtree) both fail, and
* must not be reached for again here:
* - `fs.existsSync(srcPath)` re-checked at catch time: in the genuine
* double-vanish race the source is being atomically REPLACED (e.g.
* `hooks/dist`'s unlink+rename), so it can be present again by the time
* the ENOENT is handled even though the ENOENT was genuinely
* source-side. Gating the RETRY's ENOENT on it throws on exactly the
* race this function exists to tolerate (#3108 regression).
* - `err.path`: empirically, Node's `fs.linkSync` reports the SOURCE path
* in `err.path` for BOTH a missing source and a missing dest parent
* directory — it does not distinguish them either.
*
* The only discriminator that actually works is the DEST PARENT DIRECTORY,
* because `buildOverlayRepo` builds its own dest tree (`fs.mkdirSync(destDir,
* {recursive:true})` before every walk, into a private `mkdtempSync` root no
* other process touches) — so a missing dest parent is always a bug, never
* the atomic-replace race. Callers that know the dest path (`linkOrCopyFile`,
* the `copy`-mode branch in `place()`) pass it as `destPath`; when supplied,
* it REPLACES the source-existence check entirely (on both the first attempt
* and the retry): an ENOENT is tolerated as a vanished source only if the
* dest parent is confirmed present, and rethrown untouched if the dest
* parent is missing. Callers with no dest to check keep the source-only
* logic above, unchanged.
*
* @param {string} srcPath
* @param {() => void} attempt
* @param {string} [destPath] - when supplied, an ENOENT (on either attempt)
* is tolerated as a vanished source only if
* `fs.existsSync(path.dirname(destPath))`; a missing dest parent rethrows
* instead (see discriminator discussion above).
* @returns {boolean} whether the leaf was placed
*/
function placeVanishableLeaf(srcPath, attempt, destPath) {
function destParentPresent() {
return fs.existsSync(path.dirname(destPath));
}
try {
attempt();
return true;
} catch (err) {
if (!isMissingPath(err)) throw err;
if (destPath !== undefined) {
if (!destParentPresent()) throw err;
} else if (!fs.existsSync(srcPath)) {
return false;
}
try {
attempt();
return true;
} catch (retryErr) {
if (!isMissingPath(retryErr)) throw retryErr;
if (destPath !== undefined && !destParentPresent()) throw retryErr;
return false;
}
}
}
/** Hard-link a file, falling back to a real copy only if the two paths sit on
* different filesystems/devices (EXDEV) or linking is denied (EPERM) — both
* cross-platform-legitimate, unlike a symlink's Dirent type-detection gap.
* Returns whether the leaf was placed; false means the source vanished
* mid-walk (see `placeVanishableLeaf`). */
function linkOrCopyFile(src, dest) {
return placeVanishableLeaf(
src,
() => {
try {
fs.linkSync(src, dest);
} catch (err) {
if (err.code === 'EXDEV' || err.code === 'EPERM') {
fs.copyFileSync(src, dest);
} else {
throw err;
}
}
},
dest,
);
}
/**
* Build a throwaway mirror of REPO_ROOT with real directories throughout and
* every unmodified leaf file hard-linked (or copied — see `opts.mode`
* above), except the paths named in `fileOverrides`
* (POSIX-relative-path -> content string), which are written as real files.
* Returns the mirror's absolute path; caller must
* `fs.rmSync(..., {recursive:true, force:true})` it away.
*
* @param {{[relPath: string]: string}} fileOverrides
* @param {{mode?: 'link'|'copy', warn?: (msg: string) => void}} [opts] -
* `mode` defaults to `'link'` so every pre-existing caller is unchanged.
* Pass `{mode: 'copy'}` when the overlay must survive a real `--write`
* generator run (see the module doc above) — every leaf file becomes a
* real independent inode, so no write inside the overlay can ever reach
* `REPO_ROOT`. `warn` defaults to `console.warn` (byte-identical to every
* existing caller) and exists so a test can inject a spy to assert on the
* skipped-leaf warning without capturing real console output.
*/
function buildOverlayRepo(fileOverrides, opts = {}) {
const mode = opts.mode || 'link';
const warn = opts.warn || console.warn;
const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2930-overlay-'));
const entries = Object.entries(fileOverrides).map(([relPath, content]) => ({
parts: relPath.split('/'),
content,
}));
const skipped = [];
function place(srcDir, destDir, pending, isTop) {
fs.mkdirSync(destDir, { recursive: true });
const grouped = new Map();
for (const e of pending) {
const [head, ...rest] = e.parts;
if (!grouped.has(head)) grouped.set(head, []);
grouped.get(head).push({ parts: rest, content: e.content });
}
for (const de of fs.readdirSync(srcDir, { withFileTypes: true })) {
if (isTop && OVERLAY_SKIP_TOP.has(de.name)) {
fs.symlinkSync(path.join(srcDir, de.name), path.join(destDir, de.name));
continue;
}
const srcPath = path.join(srcDir, de.name);
const destPath = path.join(destDir, de.name);
const overridden = grouped.get(de.name);
const leaf = overridden && overridden.find((s) => s.parts.length === 0);
if (leaf) {
fs.writeFileSync(destPath, leaf.content);
continue;
}
// fs.statSync follows symlinks (unlike Dirent.isDirectory()), so a
// symlinked source directory is still recursed as a REAL directory in
// the overlay — the property copyWithPathReplacement itself needs.
//
// The stat sits in the same TOCTOU window as the copy/link below: the
// name came from readdirSync, and an atomic replace elsewhere in the tree
// can retire it before we get here.
let srcStat;
try {
srcStat = fs.statSync(srcPath);
} catch (err) {
if (isMissingPath(err)) continue;
throw err;
}
if (srcStat.isDirectory()) {
place(srcPath, destPath, overridden || [], false);
} else if (mode === 'copy') {
// Real independent inode — a write through this path in the overlay
// can never alias back to REPO_ROOT's own tracked file (see
// opts.mode doc above).
const placed = placeVanishableLeaf(
srcPath,
() => fs.copyFileSync(srcPath, destPath),
destPath,
);
if (!placed) skipped.push(srcPath);
} else {
const placed = linkOrCopyFile(srcPath, destPath);
if (!placed) skipped.push(srcPath);
}
}
}
place(REPO_ROOT, tmpRepo, entries, true);
if (skipped.length > 0) {
// Not thrown: a source that left the tree mid-walk is genuinely not part of
// the snapshot, and failing here would reintroduce the crash this tolerance
// exists to remove. But it must not be SILENT either — a dropped leaf can
// surface later as a confusing "file missing" in an unrelated assertion, or
// as nothing at all for a test that never touches it.
warn(
`buildOverlayRepo: ${skipped.length} source file(s) vanished mid-walk and were ` +
`omitted from the overlay (likely a concurrent atomic replace, e.g. hooks/dist — ` +
`run \`npm run build:hooks\` to regenerate it):\n ` +
skipped.join('\n '),
);
}
return tmpRepo;
}
module.exports = { buildOverlayRepo, linkOrCopyFile, placeVanishableLeaf, isMissingPath, REPO_ROOT, OVERLAY_SKIP_TOP };