@effected/walker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @effected/walker
2
+
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fwalker?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/walker)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ Upward 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. `FileSystem` and `Path` arrive from `effect` core, so no platform package is pulled in — not even in tests.
9
+
10
+ > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
+ > development against a single pinned Effect v4 beta. Packages graduate to
12
+ > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
+ > exactly the ones the kit is built and tested against, install
14
+ > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
+ >
16
+ > **Stability: unstable.** This package's API surface is not yet considered
17
+ > complete and may change across `0.x` releases. Pin an exact version — even a
18
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
19
+ > accident, and an exact pin turns that into a type-check error rather than a
20
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
+
22
+ ## Why @effected/walker
23
+
24
+ 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 public 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.
25
+
26
+ 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.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install @effected/walker effect
32
+ ```
33
+
34
+ ```bash
35
+ pnpm add @effected/walker effect
36
+ ```
37
+
38
+ Requires Node.js >=24.11.0. `effect` v4 is a peer dependency, and it is the only one — walker has no runtime dependencies of its own.
39
+
40
+ `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 `Path.layer` and `FileSystem.layerNoop` straight from core with nothing else installed.
41
+
42
+ ## Quick start
43
+
44
+ Ascend from a directory, then look for a file in each rung of the chain:
45
+
46
+ ```ts
47
+ import { Walker } from "@effected/walker";
48
+ import { NodeFileSystem, NodePath } from "@effect/platform-node";
49
+ import { Effect, Layer, Option, Path } from "effect";
50
+
51
+ const findConfig = Effect.gen(function* () {
52
+ const path = yield* Path.Path;
53
+ const dirs = yield* Walker.ascend(process.cwd());
54
+ return yield* Walker.findUpward(dirs, (dir) => [path.join(dir, ".apprc")]);
55
+ });
56
+
57
+ const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
58
+
59
+ Effect.runPromise(findConfig.pipe(Effect.provide(PlatformLive))).then((found) => console.log(Option.getOrNull(found)));
60
+ // the path of the nearest ".apprc" at or above the cwd, e.g. "/home/you/project/.apprc"
61
+ // null when no ancestor had one — or when the one that did could not be read
62
+ ```
63
+
64
+ `findRoot` is the same loop over the directories themselves, with a marker predicate instead of a filename:
65
+
66
+ ```ts
67
+ import { Walker } from "@effected/walker";
68
+ import { Effect, FileSystem, Path } from "effect";
69
+
70
+ const findGitRoot = Effect.gen(function* () {
71
+ const fs = yield* FileSystem.FileSystem;
72
+ const path = yield* Path.Path;
73
+ const dirs = yield* Walker.ascend(process.cwd());
74
+ return yield* Walker.findRoot(dirs, (dir) => fs.exists(path.join(dir, ".git")));
75
+ });
76
+ // Effect<Option<string>, never, FileSystem | Path> — the predicate's failures are absorbed per directory
77
+ ```
78
+
79
+ 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.
80
+
81
+ ## Features
82
+
83
+ - `Walker.ascend(start, options?)` — the directory chain from `start` toward the filesystem root, nearest first. `stopAt` halts the ascent inclusively, `maxDepth` (default 256) caps it. Lexical, not physical: `Path.dirname` does not resolve symlinks, so ascending out of a symlinked directory follows the path you were given.
84
+ - `Walker.firstMatch(candidates, predicate)` — the first candidate the predicate accepts. Absorbs each predicate failure individually and short-circuits at the first match.
85
+ - `Walker.findUpward(dirs, candidatesFor)` — the first existing path, directory-major: every candidate in the nearest directory is tried before ascending.
86
+ - `Walker.findRoot(dirs, isRoot)` — the nearest directory a marker predicate accepts. `firstMatch` where the candidate expansion is the identity.
87
+
88
+ ## License
89
+
90
+ [MIT](LICENSE)
package/Walker.js ADDED
@@ -0,0 +1,94 @@
1
+ import { Effect, FileSystem, Option, Path } from "effect";
2
+
3
+ //#region src/Walker.ts
4
+ /**
5
+ * Ascend from `start` toward the filesystem root, yielding each directory,
6
+ * nearest first.
7
+ *
8
+ * @remarks
9
+ * Lexical, not physical: `Path.dirname` does not resolve symlinks, so ascending
10
+ * out of a symlinked directory follows the path you were given rather than the
11
+ * real filesystem parent. That is deliberate — config discovery wants the file
12
+ * nearest the path the user named.
13
+ *
14
+ * Bounded twice over: `dirname` is a fixpoint at the root, and `maxDepth` guards
15
+ * a pathological `Path` implementation that never reaches one.
16
+ *
17
+ * @public
18
+ */
19
+ const ascend = (start, options) => Effect.gen(function* () {
20
+ const path = yield* Path.Path;
21
+ const maxDepth = options?.maxDepth ?? 256;
22
+ if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: maxDepth must be a positive integer, received ${maxDepth}`));
23
+ const dirs = [];
24
+ let current = start;
25
+ for (let depth = 0; depth < maxDepth; depth++) {
26
+ dirs.push(current);
27
+ if (options?.stopAt !== void 0 && current === options.stopAt) break;
28
+ const parent = path.dirname(current);
29
+ if (parent === current) break;
30
+ current = parent;
31
+ }
32
+ return dirs;
33
+ });
34
+ /**
35
+ * The first candidate whose `predicate` reports true, or `Option.none()`.
36
+ *
37
+ * @remarks
38
+ * Each predicate is absorbed **individually**: a failure on one candidate is
39
+ * treated as "this candidate did not match" and the scan continues. One
40
+ * unreadable ancestor must never abort the walk, or a permission error deep in
41
+ * the tree would hide a valid root above it. Not-found and cannot-look are
42
+ * therefore indistinguishable to the caller; discovery is best-effort.
43
+ *
44
+ * `Effect.catch` catches failures, **not defects**. A predicate that throws is
45
+ * programmer error and surfaces as a defect. Do not change this to
46
+ * `Effect.catchCause` — the distinction is load-bearing.
47
+ *
48
+ * @public
49
+ */
50
+ const firstMatch = (candidates, predicate) => Effect.gen(function* () {
51
+ for (const candidate of candidates) if (yield* Effect.catch(predicate(candidate), () => Effect.succeed(false))) return Option.some(candidate);
52
+ return Option.none();
53
+ });
54
+ /**
55
+ * The first existing path among the candidates `candidatesFor` produces for each
56
+ * directory in `dirs`, scanned in order. Nearer directories win.
57
+ *
58
+ * @remarks
59
+ * Candidates materialize up front, bounded by `dirs.length × candidatesFor`'s
60
+ * output — a few hundred strings under the default `maxDepth`.
61
+ *
62
+ * @public
63
+ */
64
+ const findUpward = (dirs, candidatesFor) => Effect.gen(function* () {
65
+ const fs = yield* FileSystem.FileSystem;
66
+ const candidates = [];
67
+ for (const dir of dirs) candidates.push(...candidatesFor(dir));
68
+ return yield* firstMatch(candidates, (candidate) => fs.exists(candidate));
69
+ });
70
+ /**
71
+ * The first directory in `dirs` that `isRoot` accepts.
72
+ *
73
+ * @remarks
74
+ * `firstMatch` over the directories themselves — the candidate expansion is the
75
+ * identity. `isRoot` is a caller-supplied marker test (a `.git` entry, a
76
+ * `pnpm-workspace.yaml`), and its failures are absorbed per directory.
77
+ *
78
+ * @public
79
+ */
80
+ const findRoot = (dirs, isRoot) => firstMatch(dirs, isRoot);
81
+ /**
82
+ * Upward path traversal primitives.
83
+ *
84
+ * @public
85
+ */
86
+ const Walker = {
87
+ ascend,
88
+ firstMatch,
89
+ findUpward,
90
+ findRoot
91
+ };
92
+
93
+ //#endregion
94
+ export { Walker };
package/index.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { Effect, FileSystem, Option, Path } from "effect";
2
+ //#region src/Walker.d.ts
3
+ /**
4
+ * Options for {@link Walker.ascend}.
5
+ *
6
+ * @public
7
+ */
8
+ interface AscendOptions {
9
+ /** Stop after this directory, inclusive. */
10
+ readonly stopAt?: string;
11
+ /** Hard cap on chain length. Defaults to 256. */
12
+ readonly maxDepth?: number;
13
+ }
14
+ /**
15
+ * Upward path traversal primitives.
16
+ *
17
+ * @public
18
+ */
19
+ declare const Walker: {
20
+ readonly ascend: (start: string, options?: AscendOptions) => Effect.Effect<ReadonlyArray<string>, never, Path.Path>;
21
+ readonly firstMatch: <E, R>(candidates: ReadonlyArray<string>, predicate: (candidate: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
22
+ readonly findUpward: (dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>) => Effect.Effect<Option.Option<string>, never, FileSystem.FileSystem>;
23
+ readonly findRoot: <E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
24
+ };
25
+ //#endregion
26
+ export { type AscendOptions, Walker };
27
+ //# sourceMappingURL=index.d.ts.map
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { Walker } from "./Walker.js";
2
+
3
+ export { Walker };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@effected/walker",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Upward path traversal as Effect primitives: ascend a directory chain and return the first candidate satisfying a predicate.",
6
+ "keywords": [
7
+ "path",
8
+ "traversal",
9
+ "walk",
10
+ "find-up",
11
+ "effect",
12
+ "effected"
13
+ ],
14
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/walker#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/spencerbeggs/effected/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/spencerbeggs/effected.git",
21
+ "directory": "packages/walker"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "C. Spencer Beggs",
26
+ "email": "spencer@beggs.codes",
27
+ "url": "https://spencerbeg.gs"
28
+ },
29
+ "sideEffects": false,
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./index.d.ts",
34
+ "import": "./index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "peerDependencies": {
39
+ "effect": "4.0.0-beta.98"
40
+ },
41
+ "engines": {
42
+ "node": ">=24.11.0"
43
+ }
44
+ }
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.58.9"
9
+ }
10
+ ]
11
+ }