@effected/walker
Path traversal as Effect primitives. Walker.ascend gives you the directory chain from a starting path to the filesystem root; Walker.findUpward returns the nearest existing file among per-directory candidates; Walker.findRoot returns the nearest directory a marker predicate accepts. Every probe absorbs its own failure, so a single unreadable ancestor cannot hide a valid .git or pnpm-workspace.yaml above it. Going the other way, descend expands a compiled @effected/glob pattern under a directory into the matching file paths — sorted, symlink-safe, and typed about unreadable subtrees instead of silently swallowing them. FileSystem and Path arrive from effect core, so no platform package is pulled in — not even in tests.
Pre-
1.0.0. This package is part of the@effected/*kit, built on stable Effect v4 (effect~4.1.0) and still in0.xdevelopment. Stable Effect makes a kit1.0.0possible, not automatic. To keep youreffectand@effect/*versions on the line the kit is built and tested against, install@effected/pnpm-plugin-effected.Stability: unstable. This package's API surface is not yet considered complete and may change across
0.xreleases. Pin an exact version — even a package marked stable before1.0.0can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.
Why @effected/walker
Walking up a directory tree looks like four lines of code until you meet the edges, and the edges are where the hand-rolled version quietly gets it wrong. If a probe on one ancestor fails — an EACCES on a directory you had no business reading anyway — the naive loop propagates that error and the whole search fails, so an unreadable directory hides the workspace root sitting one level above it. Walker absorbs each probe individually: a failed probe means "this candidate did not match", never "abort the scan". Every upward error channel is never as a result, and the trade is stated up front rather than discovered later — not-found and cannot-look are deliberately indistinguishable, because discovery is best-effort. The downward walk makes the opposite call on purpose: an unreadable subtree during glob expansion fails typed by default, because "no matches there" would be a wrong answer dressed as an empty one.
The other edges get the same treatment. Absorption uses Effect.catch, which catches failures and not defects, so a predicate that throws is still programmer error and still surfaces. findUpward scans directory-major — every candidate in the nearest directory is exhausted before ascending — so a distant ancestor's .apprc can never beat a nearer config/.apprc. maxDepth must be a positive integer: NaN, 2.5 and 0 are defects rather than a silently empty chain, which is the failure mode that looks exactly like "nothing found". And start is required — walker never reads process.cwd() on your behalf.
Install
npm install @effected/walker effect
pnpm add @effected/walker effect
Requires Node.js >=24.11.0. effect v4 and @effected/glob are peer dependencies — walker has no runtime dependencies of its own, and the glob peer is consumed as types plus matches() calls only.
All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.
Path and FileSystem come from effect core, not from a platform package, so a consumer provides them once at the edge (@effect/platform-node on Node, @effect/platform-bun on Bun) and a test provides core's Path.layer plus an in-memory volume from @effected/memfs — no platform package installed.
Quick start
Ascend from a directory, then look for a file in each rung of the chain:
import { Walker } from "@effected/walker";
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
import * as NodePath from "@effect/platform-node/NodePath";
import { Effect, Layer, Option, Path } from "effect";
const findConfig = Effect.gen(function* () {
const path = yield* Path.Path;
const dirs = yield* Walker.ascend(process.cwd());
return yield* Walker.findUpward(dirs, (dir) => [path.join(dir, ".apprc")]);
});
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
Effect.runPromise(findConfig.pipe(Effect.provide(PlatformLive))).then((found) => console.log(Option.getOrNull(found)));
// the path of the nearest ".apprc" at or above the cwd, e.g. "/home/you/project/.apprc"
// null when no ancestor had one — or when the one that did could not be read
findRoot is the same loop over the directories themselves, with a marker predicate instead of a filename:
import { Walker } from "@effected/walker";
import { Effect, FileSystem, Path } from "effect";
const findGitRoot = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dirs = yield* Walker.ascend(process.cwd());
return yield* Walker.findRoot(dirs, (dir) => fs.exists(path.join(dir, ".git")));
});
// Effect<Option<string>, never, FileSystem | Path> — the predicate's failures are absorbed per directory
The predicate can be expensive — reading and parsing a package.json to decide whether a directory is a workspace root, say — because the scan short-circuits at the first match and never probes the rest.
Features
The upward walk lives on the Walker class as statics; the downward walk is two free-standing functions. Import them side by side — there is no Walker.descend:
import { Walker, compileAndExpand, descend } from "@effected/walker";
Walker.ascend(start, options?)— the directory chain fromstarttoward the filesystem root, nearest first.stopAthalts the ascent inclusively — it must be absolute, and is matched in normalized form, so a trailing separator or a./..segment still stops where it names; a relative ceiling is a defect rather than being resolved against the working directory, exactly as an invalidmaxDepthis.maxDepth(default 256) caps the chain. Lexical, not physical:Path.dirnamedoes not resolve symlinks, so ascending out of a symlinked directory follows the path you were given.Walker.ascendWithin(start, ceiling, options?)—ascendbounded by a physical ceiling, passed as anOption<string>so an unbounded walk is only ever asked for explicitly (Option.none()behaves exactly likeascend(start), the answer outside any repository): it stops at the nearest directory whose real path is the ceiling's real path. Reach for it when the ceiling resolves symlinks —Git.repoRootdoes — becauseascend's lexicalstopAtnever matches such a ceiling from a start reached through a symlink (every macOS tmpdir, any symlinked checkout), and the ascent runs to the filesystem root. The chain stays lexical; a ceiling the chain already spells costs no I/O, and an ancestor whoserealPathfails is absorbed as "not the ceiling".Walker.firstMatch(candidates, predicate)— the first candidate the predicate accepts. Absorbs each predicate failure individually and short-circuits at the first match.Walker.findUpward(dirs, candidatesFor)— the first existing path, directory-major: every candidate in the nearest directory is tried before ascending.Walker.findRoot(dirs, isRoot)— the nearest directory a marker predicate accepts.firstMatchwhere the candidate expansion is the identity.descend(pattern, options)— the file paths a compiled@effected/globpattern selects undercwd, POSIX separators, sorted.onUnreadabledecides what an unreadable directory mid-walk means:"fail"(the default) raises a typedDescendError,"skip"continues past it and forgets it, and"record"continues past it and returns aDescendResult—{ matches, unreadable }, where eachUnreadableDirectorycarries the directory'scwd-relativepathand thePlatformErrorit failed with, so a report never has to re-read the directory to learn why. The walk base records aspath: "", and a directory that vanished mid-walk (NotFound) is a benign race, never recorded.followSymlinks(defaultfalse) decides symlinked directories: by default they are never entered (cycle safety); undertruethey are descended under@actions/glob'straversalChaincycle guard — a directory is a cycle only when its real path is already an ancestor of the current branch, so link loops terminate while two sibling links resolving to the same target both enumerate; a link whose real path cannot be resolved is never entered, and that failure followsonUnreadableexactly as an unreadable directory does — matching@actions/glob's defaultfollowSymbolicLinks: true(Node's recursivereaddiralso follows links, but keeps no traversal chain and recurses without bound on a loop).descendreads only the directories a match can lie in. Without a globstar it never reads deeper than the pattern's segments can reach, sopackages/*/README.mdreadspackagesand eachpackages/<name>and nothing below them; and underdot: falseit never enters a hidden directory unless a segment of the pattern names one. Neither bound changes what matches, andmaxDepthapplies only to directories the walk actually reads.Walker.findUpwardSyncWith(dirs, accept, options?)— the nearest directory indirsthataccepttakes (acceptanswers anOption), with the value it answered, orOption.none(). It searches upward and validates what it finds, synchronously: anacceptthat reads and parsespackage.jsonand answersOption.none()for one that is missing or malformed finds the nearest valid manifest. TakedirsfromascendSync,ascendWithinSyncorascendToMarkerSync, so it stops where they stop.options.cache(aMap<string, Option<A>>, or anything with itsgetandset) memoizes each directory's answer, rejections included, so a hot path asks about each ancestor once; invalidating it is the caller's job.Walker.findUpwardWith(dirs, accept, options?)—findUpwardSyncWithwith an effectfulaccept(dir => Effect<Option<A>, E, R>), over the chainsascend,ascendWithinandascendToMarkergive. Same scan, sameoptions.cache. A failure ofacceptfails the search and caches nothing for that directory; recover insideacceptto treat a failed read as "not valid".WalkerSyncFileSystem.readFile(path)— the sync port reads files too, as UTF-8 text (readFileSync(path, "utf8")innodeWalkerFileSystem, and what an@effected/memfshandle'ssyncport answers), so code that walks and then reads what it found needs one port.PosixPath— POSIX path arithmetic as plain synchronous functions that never read the working directory:join,normalize,relative,dirname,basename,extname,isAbsoluteandsep, ported from the POSIX implementation behind core'sPath.layerand giving its answers. There is noresolve.relativeanswers two absolute paths as core does, and two relative paths whenever the answer does not depend on the working directory's name; otherwise it throws. Reach for it in pure code that cannot putPathinR; keepPathinRwherever the platform should choose POSIX or win32.compileAndExpand(pattern, options)—descendfrom a glob string: compiles it with@effected/glob(options.glob) and expands it, failing typed withGlobExpansionErrorwhen the pattern does not compile.confineRelativeResult(base, candidate)— keep an untrusted relative path (an archive entry, a manifest's asset path) underbase, synchronously, as aResult<string, PathEscapeError>. Both/and\are separators; a NUL character, an absolute form (POSIX/x, UNC\\server\share, win32C:\xorC:x), a..that climbs abovebaseafter normalisation (og/../xis fine,og/../../xis not) a path namingbaseitself ("",.,og/..), and a segment win32 reserves (a:such as an alternate data stream, dots and spaces only, a device name such asCONornul.txt) are refused, with the reason on the error. Lexical and IO-free, with no percent-decoding; the success value isbasejoined with the normalised path. Symlinks are out of scope: the check is lexical.confineRelative(base, candidate)is theEffecttwin.