@effected/walker

中文文档
0.29.0发布于 2026/11/13MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 1.3万

Path traversal as Effect primitives: ascend a directory chain to the first matching candidate, or descend a tree expanding a glob against the filesystem.

原始使用文档
文档目录5 节
  1. Why @effected/walker
  2. Install
  3. Quick start
  4. Features
  5. License

@effected/walker

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

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 in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @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.x releases. Pin an exact version — even a package marked stable before 1.0.0 can 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 from start toward the filesystem root, nearest first. stopAt halts 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 invalid maxDepth is. maxDepth (default 256) caps the chain. Lexical, not physical: Path.dirname does not resolve symlinks, so ascending out of a symlinked directory follows the path you were given.
  • Walker.ascendWithin(start, ceiling, options?) — ascend bounded by a physical ceiling, passed as an Option<string> so an unbounded walk is only ever asked for explicitly (Option.none() behaves exactly like ascend(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.repoRoot does — because ascend's lexical stopAt never 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 whose realPath fails 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. firstMatch where the candidate expansion is the identity.
  • descend(pattern, options) — the file paths a compiled @effected/glob pattern selects under cwd, POSIX separators, sorted. onUnreadable decides what an unreadable directory mid-walk means: "fail" (the default) raises a typed DescendError, "skip" continues past it and forgets it, and "record" continues past it and returns a DescendResult — { matches, unreadable }, where each UnreadableDirectory carries the directory's cwd-relative path and the PlatformError it failed with, so a report never has to re-read the directory to learn why. The walk base records as path: "", and a directory that vanished mid-walk (NotFound) is a benign race, never recorded. followSymlinks (default false) decides symlinked directories: by default they are never entered (cycle safety); under true they are descended under @actions/glob's traversalChain cycle 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 follows onUnreadable exactly as an unreadable directory does — matching @actions/glob's default followSymbolicLinks: true (Node's recursive readdir also follows links, but keeps no traversal chain and recurses without bound on a loop).
  • descend reads only the directories a match can lie in. Without a globstar it never reads deeper than the pattern's segments can reach, so packages/*/README.md reads packages and each packages/<name> and nothing below them; and under dot: false it never enters a hidden directory unless a segment of the pattern names one. Neither bound changes what matches, and maxDepth applies only to directories the walk actually reads.
  • Walker.findUpwardSyncWith(dirs, accept, options?) — the nearest directory in dirs that accept takes (accept answers an Option), with the value it answered, or Option.none(). It searches upward and validates what it finds, synchronously: an accept that reads and parses package.json and answers Option.none() for one that is missing or malformed finds the nearest valid manifest. Take dirs from ascendSync, ascendWithinSync or ascendToMarkerSync, so it stops where they stop. options.cache (a Map<string, Option<A>>, or anything with its get and set) 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?) — findUpwardSyncWith with an effectful accept (dir => Effect<Option<A>, E, R>), over the chains ascend, ascendWithin and ascendToMarker give. Same scan, same options.cache. A failure of accept fails the search and caches nothing for that directory; recover inside accept to treat a failed read as "not valid".
  • WalkerSyncFileSystem.readFile(path) — the sync port reads files too, as UTF-8 text (readFileSync(path, "utf8") in nodeWalkerFileSystem, and what an @effected/memfs handle's sync port 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, isAbsolute and sep, ported from the POSIX implementation behind core's Path.layer and giving its answers. There is no resolve. relative answers 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 put Path in R; keep Path in R wherever the platform should choose POSIX or win32.
  • compileAndExpand(pattern, options) — descend from a glob string: compiles it with @effected/glob (options.glob) and expands it, failing typed with GlobExpansionError when the pattern does not compile.
  • confineRelativeResult(base, candidate) — keep an untrusted relative path (an archive entry, a manifest's asset path) under base, synchronously, as a Result<string, PathEscapeError>. Both / and \ are separators; a NUL character, an absolute form (POSIX /x, UNC \\server\share, win32 C:\x or C:x), a .. that climbs above base after normalisation (og/../x is fine, og/../../x is not) a path naming base itself ("", ., og/..), and a segment win32 reserves (a : such as an alternate data stream, dots and spaces only, a device name such as CON or nul.txt) are refused, with the reason on the error. Lexical and IO-free, with no percent-decoding; the success value is base joined with the normalised path. Symlinks are out of scope: the check is lexical. confineRelative(base, candidate) is the Effect twin.

License

MIT

版本与发布记录

@effected/walker 共发布 40 个版本,首次发布于 2026/07/17;过去 12 个月发布了 40 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.29.0latest2026/11/13今天
0.28.02026/11/112 天前
0.27.02026/11/102 天前
0.26.02026/11/094 天前
0.25.12026/11/067 天前
0.25.02026/11/0310 天前

关于 @effected/walker 的常见问题

@effected/walker 是什么?

Path traversal as Effect primitives: ascend a directory chain to the first matching candidate, or descend a tree expanding a glob against the filesystem.

如何安装 @effected/walker?

在项目目录运行 npm install @effected/walker,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)、@effected/glob(^0.22.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/walker 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/walker 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/walker 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/walker 可以商用吗?

@effected/walker 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/walker 的下载量有多少?

根据 npm 官方下载统计,@effected/walker 在 2026/10/13 – 2026/11/11 这 30 天内被下载 13,206 次,日均约 440 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/walker 还在维护吗?

最新版本 0.29.0 发布于 2026/11/13(今天);至今共发布 40 个版本,其中过去 12 个月发布了 40 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。