@effected/walker 0.12.0 → 0.14.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/README.md CHANGED
@@ -82,11 +82,19 @@ The predicate can be expensive — reading and parsing a `package.json` to decid
82
82
 
83
83
  ## Features
84
84
 
85
+ 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`:
86
+
87
+ ```ts
88
+ import { Walker, compileAndExpand, descend } from "@effected/walker";
89
+ ```
90
+
85
91
  - `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.
92
+ - `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".
86
93
  - `Walker.firstMatch(candidates, predicate)` — the first candidate the predicate accepts. Absorbs each predicate failure individually and short-circuits at the first match.
87
94
  - `Walker.findUpward(dirs, candidatesFor)` — the first existing path, directory-major: every candidate in the nearest directory is tried before ascending.
88
95
  - `Walker.findRoot(dirs, isRoot)` — the nearest directory a marker predicate accepts. `firstMatch` where the candidate expansion is the identity.
89
96
  - `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).
97
+ - `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.
90
98
 
91
99
  ## License
92
100
 
package/Walker.js CHANGED
@@ -18,6 +18,25 @@ const ascend = (start, options) => Effect.gen(function* () {
18
18
  }
19
19
  return dirs;
20
20
  });
21
+ const ascendWithin = (start, ceiling, options) => Effect.gen(function* () {
22
+ if (Option.isNone(ceiling)) return yield* ascend(start, options);
23
+ return yield* ascendToPhysical(start, ceiling.value, options);
24
+ });
25
+ const ascendToPhysical = (start, ceiling, options) => Effect.gen(function* () {
26
+ const fs = yield* FileSystem.FileSystem;
27
+ const path = yield* Path.Path;
28
+ if (!path.isAbsolute(ceiling)) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascendWithin: ceiling must be an absolute path, received ${JSON.stringify(ceiling)} (ascending from ${JSON.stringify(start)})`));
29
+ const dirs = yield* ascend(start, {
30
+ ...options,
31
+ stopAt: ceiling
32
+ });
33
+ const last = dirs.at(-1);
34
+ if (last !== void 0 && path.resolve(last) === path.resolve(ceiling)) return dirs;
35
+ const physical = yield* Effect.option(fs.realPath(ceiling));
36
+ if (Option.isNone(physical)) return dirs;
37
+ const hit = yield* findRoot(dirs, (dir) => Effect.map(fs.realPath(dir), (real) => real === physical.value));
38
+ return Option.isSome(hit) ? dirs.slice(0, dirs.indexOf(hit.value) + 1) : dirs;
39
+ });
21
40
  const firstMatch = (candidates, predicate) => Effect.gen(function* () {
22
41
  for (const candidate of candidates) if (yield* Effect.catch(predicate(candidate), () => Effect.succeed(false))) return Option.some(candidate);
23
42
  return Option.none();
@@ -60,6 +79,34 @@ var Walker = class {
60
79
  */
61
80
  static ascend = ascend;
62
81
  /**
82
+ * {@link Walker.ascend} bounded by a PHYSICAL ceiling: stops at the nearest
83
+ * directory whose real path is the ceiling's real path, inclusive.
84
+ *
85
+ * @remarks
86
+ * Use this when the ceiling came from something that resolves symlinks —
87
+ * `Git.repoRoot` (`git rev-parse --show-toplevel`) above all. `ascend`
88
+ * compares `stopAt` lexically, so when `start` is reached through a symlink
89
+ * (every macOS tmpdir: `/var` → `/private/var`; any symlinked checkout) the
90
+ * chain never spells git's root, the ceiling never matches, and the walk
91
+ * runs silently to the filesystem root.
92
+ *
93
+ * The chain is still the lexical one derived from `start`, exactly as
94
+ * `ascend` yields it; only the stopping test is physical. A ceiling the
95
+ * chain already spells stops without touching the filesystem. Otherwise each
96
+ * ancestor's `realPath` is compared with the ceiling's, and a failed probe
97
+ * is absorbed as "not the ceiling", as {@link Walker.firstMatch} absorbs
98
+ * one. A ceiling that names no ancestor, or cannot itself be resolved,
99
+ * leaves the chain running to the filesystem root, as `ascend` does.
100
+ *
101
+ * The ceiling is an `Option` so "no ceiling" is spelled explicitly:
102
+ * `Option.none()` ascends to the filesystem root exactly as `ascend(start)`
103
+ * does, which is the natural answer outside any repository —
104
+ * `Walker.ascendWithin(start, yield* Effect.option(git.repoRoot(start)))`.
105
+ * A present ceiling must be absolute; a relative one **dies**, for the
106
+ * reasons {@link AscendOptions.stopAt} gives.
107
+ */
108
+ static ascendWithin = ascendWithin;
109
+ /**
63
110
  * The first candidate whose `predicate` reports true, or `Option.none()`.
64
111
  *
65
112
  * @remarks
package/index.d.ts CHANGED
@@ -330,6 +330,11 @@ interface AscendOptions {
330
330
  * still the lexical one derived from `start`, unrewritten. A ceiling naming
331
331
  * no ancestor of `start` never matches and the ascent runs to the
332
332
  * filesystem root.
333
+ *
334
+ * **Lexical, never physical.** A ceiling spelled with symlinks resolved —
335
+ * `Git.repoRoot`'s answer is one — does not match a chain reached through a
336
+ * symlink, and the ascent runs to the filesystem root. Use
337
+ * {@link Walker.ascendWithin} for a physical ceiling.
333
338
  */
334
339
  readonly stopAt?: string;
335
340
  /** Hard cap on chain length. Defaults to 256. */
@@ -365,6 +370,34 @@ export declare class Walker {
365
370
  * rather than a typed error; the error channel stays `never`.
366
371
  */
367
372
  static readonly ascend: (start: string, options?: AscendOptions) => Effect.Effect<ReadonlyArray<string>, never, Path.Path>;
373
+ /**
374
+ * {@link Walker.ascend} bounded by a PHYSICAL ceiling: stops at the nearest
375
+ * directory whose real path is the ceiling's real path, inclusive.
376
+ *
377
+ * @remarks
378
+ * Use this when the ceiling came from something that resolves symlinks —
379
+ * `Git.repoRoot` (`git rev-parse --show-toplevel`) above all. `ascend`
380
+ * compares `stopAt` lexically, so when `start` is reached through a symlink
381
+ * (every macOS tmpdir: `/var` → `/private/var`; any symlinked checkout) the
382
+ * chain never spells git's root, the ceiling never matches, and the walk
383
+ * runs silently to the filesystem root.
384
+ *
385
+ * The chain is still the lexical one derived from `start`, exactly as
386
+ * `ascend` yields it; only the stopping test is physical. A ceiling the
387
+ * chain already spells stops without touching the filesystem. Otherwise each
388
+ * ancestor's `realPath` is compared with the ceiling's, and a failed probe
389
+ * is absorbed as "not the ceiling", as {@link Walker.firstMatch} absorbs
390
+ * one. A ceiling that names no ancestor, or cannot itself be resolved,
391
+ * leaves the chain running to the filesystem root, as `ascend` does.
392
+ *
393
+ * The ceiling is an `Option` so "no ceiling" is spelled explicitly:
394
+ * `Option.none()` ascends to the filesystem root exactly as `ascend(start)`
395
+ * does, which is the natural answer outside any repository —
396
+ * `Walker.ascendWithin(start, yield* Effect.option(git.repoRoot(start)))`.
397
+ * A present ceiling must be absolute; a relative one **dies**, for the
398
+ * reasons {@link AscendOptions.stopAt} gives.
399
+ */
400
+ static readonly ascendWithin: (start: string, ceiling: Option.Option<string>, options?: Pick<AscendOptions, "maxDepth">) => Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path>;
368
401
  /**
369
402
  * The first candidate whose `predicate` reports true, or `Option.none()`.
370
403
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/walker",
3
- "version": "0.12.0",
3
+ "version": "0.14.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.8.0",
41
- "effect": "4.0.0-rc.117"
40
+ "@effected/glob": "^0.9.0",
41
+ "effect": "4.0.0-rc.118"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=24.11.0"