@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 +21 -0
- package/README.md +90 -0
- package/Walker.js +94 -0
- package/index.d.ts +27 -0
- package/index.js +3 -0
- package/package.json +44 -0
- package/tsdoc-metadata.json +11 -0
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
|
+
[](https://www.npmjs.com/package/@effected/walker)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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
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
|
+
}
|