@effected/walker 0.10.0 → 0.12.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/Descend.js CHANGED
@@ -41,6 +41,8 @@ const escapesCwd = (relative) => {
41
41
  }
42
42
  return false;
43
43
  };
44
+ /** Shared empty ancestor chain: frames under `followSymlinks: false` never consult theirs. */
45
+ const NO_ANCESTORS = Object.freeze([]);
44
46
  /** Not exported — `descend`'s overloads below carry the public documentation. */
45
47
  const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
46
48
  const fs = yield* FileSystem.FileSystem;
@@ -49,11 +51,41 @@ const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
49
51
  if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.descend: maxDepth must be a positive integer, received ${maxDepth}`));
50
52
  const prune = new Set(options.prune ?? DEFAULT_PRUNE);
51
53
  const onUnreadable = options.onUnreadable ?? "fail";
54
+ const followSymlinks = options.followSymlinks ?? false;
52
55
  const unreadable = [];
53
56
  /** The stat-resolved type of `absolute`, or `undefined` when it does not resolve (missing, dangling symlink, unstatable). */
54
57
  const typeOf = (absolute) => fs.stat(absolute).pipe(Effect.map((info) => info.type), Effect.orElseSucceed(() => void 0));
55
58
  /** Whether `absolute` is itself a symlink. `readLink` succeeds only on links; any failure means "not one". */
56
59
  const isSymbolicLink = (absolute) => fs.readLink(absolute).pipe(Effect.map(() => true), Effect.orElseSucceed(() => false));
60
+ /**
61
+ * The real path of a directory the cycle guard must identify — the walk
62
+ * base, or a symlinked directory — or `undefined` when the walk must not
63
+ * enter it. A link the resolver cannot resolve is one the guard cannot
64
+ * reason about: recording a stand-in (the link's own path) would make
65
+ * every hop around a loop look unseen, so the subtree is never queued.
66
+ * Whether the caller HEARS about it follows `onUnreadable` exactly as a
67
+ * failed `readDirectory` does: a `NotFound` is the benign vanished-target
68
+ * race (the subtree would have read as empty anyway) and stays silent in
69
+ * every mode; anything else — `EACCES` on a path component, above all —
70
+ * is a subtree this walk was asked to enumerate and cannot, so the
71
+ * default fails typed, `"record"` keeps the path and the cause, and
72
+ * `"skip"` forgets it. A silent skip here would hand a caller a smaller
73
+ * answer with nothing reporting it, which is the failure `"fail"` exists
74
+ * to prevent.
75
+ */
76
+ const realPathOf = (absolute, relative) => fs.realPath(absolute).pipe(Effect.map((real) => real), Effect.catch((error) => {
77
+ if (error.reason._tag === "NotFound") return Effect.succeed(void 0);
78
+ if (onUnreadable === "fail") return Effect.fail(new DescendError({
79
+ pattern: pattern.source,
80
+ reason: "unreadableDirectory",
81
+ path: relative
82
+ }));
83
+ if (onUnreadable === "record") unreadable.push({
84
+ path: relative,
85
+ cause: error
86
+ });
87
+ return Effect.succeed(void 0);
88
+ }));
57
89
  /** Wrap the walk's success value per `onUnreadable`: a plain array unless "record" asked for the pair. */
58
90
  const finish = (matches) => onUnreadable === "record" ? {
59
91
  matches,
@@ -67,12 +99,19 @@ const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
67
99
  if (escapesCwd(base)) return finish([]);
68
100
  const absoluteBase = base === "" ? options.cwd : path.join(options.cwd, base);
69
101
  if ((yield* typeOf(absoluteBase)) !== "Directory") return finish([]);
102
+ let baseAncestors = NO_ANCESTORS;
103
+ if (followSymlinks) {
104
+ const real = yield* realPathOf(absoluteBase, base);
105
+ if (real === void 0) return finish([]);
106
+ baseAncestors = [real];
107
+ }
70
108
  const deep = pattern.crossesSegments || pattern.negated;
71
109
  const results = [];
72
110
  const frames = [{
73
111
  relative: base,
74
112
  absolute: absoluteBase,
75
- depth: 0
113
+ depth: 0,
114
+ ancestors: baseAncestors
76
115
  }];
77
116
  for (let head = 0; head < frames.length; head += 1) {
78
117
  const frame = frames[head];
@@ -100,7 +139,13 @@ const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
100
139
  }
101
140
  if (kind !== "Directory" || !deep) continue;
102
141
  if (prune.has(entry)) continue;
103
- if (yield* isSymbolicLink(absolute)) continue;
142
+ let ancestors = frame.ancestors;
143
+ if (followSymlinks) {
144
+ const parentReal = frame.ancestors[frame.ancestors.length - 1] ?? frame.absolute;
145
+ const real = (yield* isSymbolicLink(absolute)) ? yield* realPathOf(absolute, relative) : path.join(parentReal, entry);
146
+ if (real === void 0 || frame.ancestors.includes(real)) continue;
147
+ ancestors = [...frame.ancestors, real];
148
+ } else if (yield* isSymbolicLink(absolute)) continue;
104
149
  if (frame.depth + 1 > maxDepth) return yield* new DescendError({
105
150
  pattern: pattern.source,
106
151
  reason: "depthExceeded",
@@ -110,7 +155,8 @@ const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
110
155
  frames.push({
111
156
  relative,
112
157
  absolute,
113
- depth: frame.depth + 1
158
+ depth: frame.depth + 1,
159
+ ancestors
114
160
  });
115
161
  }
116
162
  }
package/README.md CHANGED
@@ -86,7 +86,7 @@ The predicate can be expensive — reading and parsing a `package.json` to decid
86
86
  - `Walker.firstMatch(candidates, predicate)` — the first candidate the predicate accepts. Absorbs each predicate failure individually and short-circuits at the first match.
87
87
  - `Walker.findUpward(dirs, candidatesFor)` — the first existing path, directory-major: every candidate in the nearest directory is tried before ascending.
88
88
  - `Walker.findRoot(dirs, isRoot)` — the nearest directory a marker predicate accepts. `firstMatch` where the candidate expansion is the identity.
89
- - `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.
89
+ - `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).
90
90
 
91
91
  ## License
92
92
 
package/index.d.ts CHANGED
@@ -24,6 +24,27 @@ interface DescendOptions {
24
24
  * returns a bare match array.
25
25
  */
26
26
  readonly onUnreadable?: "fail" | "skip";
27
+ /**
28
+ * Whether to descend into symlinked directories. Defaults to `false`: a
29
+ * symlinked directory is never entered (cycle safety). Under `true` links
30
+ * are followed with the cycle guard kept underneath, in `@actions/glob`'s
31
+ * `traversalChain` semantics: each descended directory records its real
32
+ * path on its own branch's ancestor chain (only the base and each link pay
33
+ * a `FileSystem.realPath`; a plain directory's is derived from its
34
+ * parent's), and a directory whose real path is already an ancestor of the
35
+ * current branch closes a cycle and is skipped — so link loops terminate
36
+ * while two sibling links resolving to the same target both enumerate. A
37
+ * link whose real path cannot be resolved is never entered, and the
38
+ * failure follows `onUnreadable` exactly as an unreadable directory does:
39
+ * `NotFound` is a benign race and stays silent, anything else fails typed
40
+ * by default, is recorded under `"record"`, and is forgotten under
41
+ * `"skip"`. Following links matches `@actions/glob`'s default
42
+ * `followSymbolicLinks: true` and Node's recursive
43
+ * `fs.promises.readdir` — but the cycle guard is `@actions/glob`'s
44
+ * alone: Node's recursive `readdir` keeps no traversal chain and recurses
45
+ * without bound on a link loop.
46
+ */
47
+ readonly followSymlinks?: boolean;
27
48
  }
28
49
  /**
29
50
  * Options for `descend` under `onUnreadable: "record"`: every
@@ -48,7 +69,8 @@ interface DescendRecordOptions extends Omit<DescendOptions, "onUnreadable"> {
48
69
  }
49
70
  /**
50
71
  * One directory `descend` could not read under `onUnreadable: "record"`: its
51
- * `cwd`-relative path and the `PlatformError` that `readDirectory` failed
72
+ * `cwd`-relative path and the `PlatformError` that `readDirectory` — or,
73
+ * for a symlinked directory under `followSymlinks`, `realPath` — failed
52
74
  * with. The cause is the very failure the walk absorbed, so a caller that
53
75
  * must report WHY a directory was unreadable never re-reads it.
54
76
  *
@@ -65,14 +87,15 @@ interface UnreadableDirectory {
65
87
  * these entries as ordinary paths will not expect that; special-case it.
66
88
  */
67
89
  readonly path: string;
68
- /** The `readDirectory` failure, never `NotFound` (a vanished directory is a benign race and is not recorded). */
90
+ /** The `readDirectory` (or, for a link under `followSymlinks`, `realPath`) failure, never `NotFound` (a vanished directory is a benign race and is not recorded). */
69
91
  readonly cause: PlatformError.PlatformError;
70
92
  }
71
93
  /**
72
94
  * `descend`'s success value under `onUnreadable: "record"`: the matched
73
95
  * FILE paths plus an {@link UnreadableDirectory} for every mid-walk
74
- * directory whose `readDirectory` failed for a reason other than `NotFound`
75
- * (a vanished directory stays a benign race in every mode and is never
96
+ * directory whose `readDirectory` (or, under `followSymlinks`, a symlinked
97
+ * directory whose `realPath`) failed for a reason other than `NotFound` (a
98
+ * vanished directory stays a benign race in every mode and is never
76
99
  * recorded).
77
100
  *
78
101
  * @public
@@ -108,7 +131,8 @@ export declare class DescendError extends DescendError_base {
108
131
  * `onUnreadable: "record"`, resolving to a {@link DescendResult} — the
109
132
  * matched FILE paths relative to `cwd` (POSIX separators, sorted) plus an
110
133
  * {@link UnreadableDirectory} — path and cause — for every mid-walk
111
- * directory whose `readDirectory` failed for a reason other than `NotFound`.
134
+ * directory whose `readDirectory` (or, under `followSymlinks`, a symlinked
135
+ * directory whose `realPath`) failed for a reason other than `NotFound`.
112
136
  * The walk continues past each such directory exactly as `"skip"` does; it
113
137
  * never aborts and it never discards the offending path or its cause.
114
138
  *
@@ -127,18 +151,28 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
127
151
  * match paths outside that prefix, so it walks from `cwd` itself. A missing
128
152
  * base directory is likewise an empty result, because zero matches is a
129
153
  * normal glob answer — as is any pattern that lexically climbs above `cwd`
130
- * via `..` segments (walked paths never contain `..`, and the walk never
131
- * reads outside its documented root). Only an unreadable directory mid-walk
154
+ * via `..` segments (walked paths never contain `..`). "Never reads outside
155
+ * its documented root" holds lexically always, and physically only while
156
+ * `followSymlinks` is off: under it, a link whose target lives outside `cwd`
157
+ * is descended, exactly as `@actions/glob` follows links out of the tree.
158
+ * Only an unreadable directory mid-walk
132
159
  * (under the default `onUnreadable: "fail"`) or a walk past `maxDepth` fails,
133
160
  * typed as {@link DescendError}.
134
161
  *
135
162
  * Only files match. A symlink counts when it stat-resolves to a file
136
163
  * (`FileSystem.stat` follows links, as node's does); a symlinked directory is
137
- * never descended into (cycle safety — detected by a `readLink` probe); a
138
- * dangling symlink is not a match. A directory that vanishes between its
139
- * parent's listing and its own read is a benign race and reads as empty. A
140
- * pattern that cannot match below one level (no globstar, no mid-pattern
141
- * magic segment) reads a single level and never descends.
164
+ * never descended into by default (cycle safety — detected by a `readLink`
165
+ * probe), unless `followSymlinks: true` asks for it, which follows links under
166
+ * `@actions/glob`'s `traversalChain` cycle guard: a directory is a cycle only
167
+ * when its real path is already an ancestor of the current branch, so sibling
168
+ * links resolving to the same target both enumerate. A link whose real path
169
+ * cannot be resolved is never entered, and a resolution failure other than
170
+ * `NotFound` is an unreadable directory under `onUnreadable`. A dangling
171
+ * symlink is not a match. A
172
+ * directory that vanishes between its parent's listing and its own read is a
173
+ * benign race and reads as empty. A pattern that cannot match below one level
174
+ * (no globstar, no mid-pattern magic segment) reads a single level and never
175
+ * descends.
142
176
  *
143
177
  * The descent is a worklist, not a recursion — it cannot overflow the stack —
144
178
  * dequeued by head index, never `Array.shift()`. Like `ascend`, `maxDepth`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/walker",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
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": [
@@ -37,8 +37,8 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "peerDependencies": {
40
- "@effected/glob": "^0.7.0",
41
- "effect": "4.0.0-rc.116"
40
+ "@effected/glob": "^0.8.0",
41
+ "effect": "4.0.0-rc.117"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=24.11.0"