@effected/walker 0.1.0 → 0.2.1

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/Descend.js ADDED
@@ -0,0 +1,139 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+
3
+ //#region src/Descend.ts
4
+ /**
5
+ * Typed failure raised by {@link descend}: a directory mid-walk was unreadable
6
+ * (under `onUnreadable: "fail"`), or the walk descended past `maxDepth`. Depth
7
+ * exhaustion is a typed failure, never a truncation — silent truncation
8
+ * silently changes match semantics.
9
+ *
10
+ * @public
11
+ */
12
+ var DescendError = class extends Schema.TaggedErrorClass()("DescendError", {
13
+ /** The glob pattern's source text. */
14
+ pattern: Schema.String,
15
+ reason: Schema.Literals(["unreadableDirectory", "depthExceeded"]),
16
+ /** The offending directory, relative to `cwd` (`""` is the walk's base). */
17
+ path: Schema.String,
18
+ /** The depth cap, present when `reason` is `"depthExceeded"`. */
19
+ limit: Schema.optionalKey(Schema.Number)
20
+ }) {
21
+ get message() {
22
+ const where = this.path === "" ? "the base directory" : JSON.stringify(this.path);
23
+ return this.reason === "depthExceeded" ? `glob descent for ${JSON.stringify(this.pattern)} descended past ${this.limit ?? "the depth cap"} levels below ${where}` : `glob descent for ${JSON.stringify(this.pattern)} could not read ${where}`;
24
+ }
25
+ };
26
+ /** Directory names never descended into unless the caller overrides `prune`. */
27
+ const DEFAULT_PRUNE = ["node_modules", ".git"];
28
+ /**
29
+ * Whether a cwd-relative pattern path lexically climbs above `cwd` via `..`
30
+ * segments. Walked paths never contain `..`, so such a pattern can never match
31
+ * one — the answer is zero matches, and no filesystem access outside `cwd`
32
+ * ever happens (a pattern must not enumerate the tree above its documented
33
+ * root).
34
+ */
35
+ const escapesCwd = (relative) => {
36
+ let depth = 0;
37
+ for (const segment of relative.split("/")) {
38
+ if (segment === "" || segment === ".") continue;
39
+ depth += segment === ".." ? -1 : 1;
40
+ if (depth < 0) return true;
41
+ }
42
+ return false;
43
+ };
44
+ /**
45
+ * Expand a compiled glob pattern against the filesystem, returning matching
46
+ * FILE paths relative to `cwd` (POSIX separators), sorted by relative path.
47
+ *
48
+ * @remarks
49
+ * A literal pattern (no magic, not negated) fast-paths to a single stat: the
50
+ * result is `[source]` when it resolves to a file, `[]` otherwise — a missing
51
+ * path is zero matches, not an error. A magic pattern walks from its literal
52
+ * directory prefix (`GlobPattern.enumerationPrefix`); a NEGATED pattern can
53
+ * match paths outside that prefix, so it walks from `cwd` itself. A missing
54
+ * base directory is likewise an empty result, because zero matches is a
55
+ * normal glob answer — as is any pattern that lexically climbs above `cwd`
56
+ * via `..` segments (walked paths never contain `..`, and the walk never
57
+ * reads outside its documented root). Only an unreadable directory mid-walk
58
+ * (under the default `onUnreadable: "fail"`) or a walk past `maxDepth` fails,
59
+ * typed as {@link DescendError}.
60
+ *
61
+ * Only files match. A symlink counts when it stat-resolves to a file
62
+ * (`FileSystem.stat` follows links, as node's does); a symlinked directory is
63
+ * never descended into (cycle safety — detected by a `readLink` probe); a
64
+ * dangling symlink is not a match. A directory that vanishes between its
65
+ * parent's listing and its own read is a benign race and reads as empty. A
66
+ * pattern that cannot match below one level (no globstar, no mid-pattern
67
+ * magic segment) reads a single level and never descends.
68
+ *
69
+ * The descent is a worklist, not a recursion — it cannot overflow the stack —
70
+ * dequeued by head index, never `Array.shift()`. Like `ascend`, `maxDepth`
71
+ * must be a positive integer: anything else is a defect, never a
72
+ * silently-empty result.
73
+ *
74
+ * @public
75
+ */
76
+ const descend = Effect.fn("Walker.descend")(function* (pattern, options) {
77
+ const fs = yield* FileSystem.FileSystem;
78
+ const path = yield* Path.Path;
79
+ const maxDepth = options.maxDepth ?? 256;
80
+ if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.descend: maxDepth must be a positive integer, received ${maxDepth}`));
81
+ const prune = new Set(options.prune ?? DEFAULT_PRUNE);
82
+ const onUnreadable = options.onUnreadable ?? "fail";
83
+ /** The stat-resolved type of `absolute`, or `undefined` when it does not resolve (missing, dangling symlink, unstatable). */
84
+ const typeOf = (absolute) => fs.stat(absolute).pipe(Effect.map((info) => info.type), Effect.orElseSucceed(() => void 0));
85
+ /** Whether `absolute` is itself a symlink. `readLink` succeeds only on links; any failure means "not one". */
86
+ const isSymbolicLink = (absolute) => fs.readLink(absolute).pipe(Effect.map(() => true), Effect.orElseSucceed(() => false));
87
+ if (!pattern.hasMagic && !pattern.negated) {
88
+ if (escapesCwd(pattern.source)) return [];
89
+ return (yield* typeOf(path.join(options.cwd, pattern.source))) === "File" ? [pattern.source] : [];
90
+ }
91
+ const base = pattern.negated ? "" : pattern.enumerationPrefix.replace(/\/+$/, "");
92
+ if (escapesCwd(base)) return [];
93
+ const absoluteBase = base === "" ? options.cwd : path.join(options.cwd, base);
94
+ if ((yield* typeOf(absoluteBase)) !== "Directory") return [];
95
+ const deep = pattern.crossesSegments || pattern.negated;
96
+ const results = [];
97
+ const frames = [{
98
+ relative: base,
99
+ absolute: absoluteBase,
100
+ depth: 0
101
+ }];
102
+ for (let head = 0; head < frames.length; head += 1) {
103
+ const frame = frames[head];
104
+ if (frame === void 0) break;
105
+ const entries = yield* fs.readDirectory(frame.absolute).pipe(Effect.catch((error) => error.reason._tag === "NotFound" || onUnreadable === "skip" ? Effect.succeed([]) : Effect.fail(new DescendError({
106
+ pattern: pattern.source,
107
+ reason: "unreadableDirectory",
108
+ path: frame.relative
109
+ }))));
110
+ for (const entry of entries) {
111
+ const relative = frame.relative === "" ? entry : `${frame.relative}/${entry}`;
112
+ const absolute = path.join(frame.absolute, entry);
113
+ const kind = yield* typeOf(absolute);
114
+ if (kind === "File") {
115
+ if (pattern.matches(relative)) results.push(relative);
116
+ continue;
117
+ }
118
+ if (kind !== "Directory" || !deep) continue;
119
+ if (prune.has(entry)) continue;
120
+ if (yield* isSymbolicLink(absolute)) continue;
121
+ if (frame.depth + 1 > maxDepth) return yield* new DescendError({
122
+ pattern: pattern.source,
123
+ reason: "depthExceeded",
124
+ path: frame.relative,
125
+ limit: maxDepth
126
+ });
127
+ frames.push({
128
+ relative,
129
+ absolute,
130
+ depth: frame.depth + 1
131
+ });
132
+ }
133
+ }
134
+ results.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
135
+ return results;
136
+ });
137
+
138
+ //#endregion
139
+ export { DescendError, descend };
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
7
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.
8
+ 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`](https://www.npmjs.com/package/@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.
9
9
 
10
10
  > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
11
  > development against a single pinned Effect v4 beta. Packages graduate to
@@ -21,7 +21,7 @@ Upward path traversal as Effect primitives. `Walker.ascend` gives you the direct
21
21
 
22
22
  ## Why @effected/walker
23
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.
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 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.
25
25
 
26
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
27
 
@@ -35,7 +35,9 @@ npm install @effected/walker effect
35
35
  pnpm add @effected/walker effect
36
36
  ```
37
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.
38
+ 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.
39
+
40
+ 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.
39
41
 
40
42
  `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
43
 
package/index.d.ts CHANGED
@@ -1,4 +1,80 @@
1
- import { Effect, FileSystem, Option, Path } from "effect";
1
+ import { GlobPattern } from "@effected/glob";
2
+ import { Effect, FileSystem, Option, Path, Schema } from "effect";
3
+ //#region src/Descend.d.ts
4
+ /**
5
+ * Options for {@link descend}.
6
+ *
7
+ * @public
8
+ */
9
+ interface DescendOptions {
10
+ /** Absolute directory the pattern is resolved against. Required — walker never reads `process.cwd()`. */
11
+ readonly cwd: string;
12
+ /** Hard cap on directory depth below the pattern's literal prefix. Defaults to 256. */
13
+ readonly maxDepth?: number;
14
+ /** Directory names never descended into. Defaults to `["node_modules", ".git"]`; a custom list replaces the default. */
15
+ readonly prune?: ReadonlyArray<string>;
16
+ /**
17
+ * What an unreadable directory mid-walk does. `"fail"` (the default) fails
18
+ * typed — downward enumeration must not silently swallow a subtree, or the
19
+ * answer is silently missing membership dressed as an empty one. `"skip"`
20
+ * absorbs the failure and continues.
21
+ */
22
+ readonly onUnreadable?: "fail" | "skip";
23
+ }
24
+ declare const DescendError_base: Schema.Class<DescendError, Schema.TaggedStruct<"DescendError", {
25
+ /** The glob pattern's source text. */
26
+ readonly pattern: Schema.String;
27
+ readonly reason: Schema.Literals<readonly ["unreadableDirectory", "depthExceeded"]>;
28
+ /** The offending directory, relative to `cwd` (`""` is the walk's base). */
29
+ readonly path: Schema.String;
30
+ /** The depth cap, present when `reason` is `"depthExceeded"`. */
31
+ readonly limit: Schema.optionalKey<Schema.Number>;
32
+ }>, import("effect/Cause").YieldableError>;
33
+ /**
34
+ * Typed failure raised by {@link descend}: a directory mid-walk was unreadable
35
+ * (under `onUnreadable: "fail"`), or the walk descended past `maxDepth`. Depth
36
+ * exhaustion is a typed failure, never a truncation — silent truncation
37
+ * silently changes match semantics.
38
+ *
39
+ * @public
40
+ */
41
+ declare class DescendError extends DescendError_base {
42
+ get message(): string;
43
+ }
44
+ /**
45
+ * Expand a compiled glob pattern against the filesystem, returning matching
46
+ * FILE paths relative to `cwd` (POSIX separators), sorted by relative path.
47
+ *
48
+ * @remarks
49
+ * A literal pattern (no magic, not negated) fast-paths to a single stat: the
50
+ * result is `[source]` when it resolves to a file, `[]` otherwise — a missing
51
+ * path is zero matches, not an error. A magic pattern walks from its literal
52
+ * directory prefix (`GlobPattern.enumerationPrefix`); a NEGATED pattern can
53
+ * match paths outside that prefix, so it walks from `cwd` itself. A missing
54
+ * base directory is likewise an empty result, because zero matches is a
55
+ * normal glob answer — as is any pattern that lexically climbs above `cwd`
56
+ * via `..` segments (walked paths never contain `..`, and the walk never
57
+ * reads outside its documented root). Only an unreadable directory mid-walk
58
+ * (under the default `onUnreadable: "fail"`) or a walk past `maxDepth` fails,
59
+ * typed as {@link DescendError}.
60
+ *
61
+ * Only files match. A symlink counts when it stat-resolves to a file
62
+ * (`FileSystem.stat` follows links, as node's does); a symlinked directory is
63
+ * never descended into (cycle safety — detected by a `readLink` probe); a
64
+ * dangling symlink is not a match. A directory that vanishes between its
65
+ * parent's listing and its own read is a benign race and reads as empty. A
66
+ * pattern that cannot match below one level (no globstar, no mid-pattern
67
+ * magic segment) reads a single level and never descends.
68
+ *
69
+ * The descent is a worklist, not a recursion — it cannot overflow the stack —
70
+ * dequeued by head index, never `Array.shift()`. Like `ascend`, `maxDepth`
71
+ * must be a positive integer: anything else is a defect, never a
72
+ * silently-empty result.
73
+ *
74
+ * @public
75
+ */
76
+ declare const descend: (pattern: GlobPattern, options: DescendOptions) => Effect.Effect<ReadonlyArray<string>, DescendError, FileSystem.FileSystem | Path.Path>;
77
+ //#endregion
2
78
  //#region src/Walker.d.ts
3
79
  /**
4
80
  * Options for {@link Walker.ascend}.
@@ -23,5 +99,5 @@ declare const Walker: {
23
99
  readonly findRoot: <E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
24
100
  };
25
101
  //#endregion
26
- export { type AscendOptions, Walker };
102
+ export { type AscendOptions, DescendError, type DescendOptions, Walker, descend };
27
103
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DescendError, descend } from "./Descend.js";
1
2
  import { Walker } from "./Walker.js";
2
3
 
3
- export { Walker };
4
+ export { DescendError, Walker, descend };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@effected/walker",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
- "description": "Upward path traversal as Effect primitives: ascend a directory chain and return the first candidate satisfying a predicate.",
5
+ "description": "Path traversal as Effect primitives: ascend a directory chain to the first matching candidate, or descend a tree expanding a glob against the filesystem.",
6
6
  "keywords": [
7
7
  "path",
8
8
  "traversal",
@@ -36,6 +36,7 @@
36
36
  "./package.json": "./package.json"
37
37
  },
38
38
  "peerDependencies": {
39
+ "@effected/glob": "0.1.1",
39
40
  "effect": "4.0.0-beta.98"
40
41
  },
41
42
  "engines": {
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.58.9"
8
+ "packageVersion": "7.58.10"
9
9
  }
10
10
  ]
11
11
  }