@effected/walker 0.21.0 → 0.22.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/index.d.ts CHANGED
@@ -20,10 +20,16 @@ import { GlobPattern, GlobPatternError, GlobPatternOptions } from "@effected/glo
20
20
  * reserved device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`,
21
21
  * `LPT1`–`LPT9`), in any case and with or without an extension
22
22
  * (`nul.txt`).
23
+ * - `outside` — `confineReal` only: the candidate's real path, links
24
+ * resolved, lies outside the base's real path.
25
+ * - `unresolvable` — `confineReal` only: resolving the candidate or the base
26
+ * failed for a reason other than absence (a permission error, a link loop,
27
+ * a file used as a directory, a dangling link), so containment cannot be
28
+ * verified. The failure rides in the error's `cause`.
23
29
  *
24
30
  * @public
25
31
  */
26
- export declare const PathEscapeReason: Schema.Literals<readonly ["nulByte", "absolute", "traversal", "empty", "reservedName"]>;
32
+ export declare const PathEscapeReason: Schema.Literals<readonly ["nulByte", "absolute", "traversal", "empty", "reservedName", "outside", "unresolvable"]>;
27
33
  /**
28
34
  * The decoded type of {@link (PathEscapeReason:variable)}.
29
35
  *
@@ -34,7 +40,9 @@ declare const PathEscapeError_base: Schema.Class<PathEscapeError, Schema.TaggedS
34
40
  /** The candidate path, exactly as the caller passed it. */
35
41
  readonly candidate: Schema.String;
36
42
  /** Why the candidate was refused. */
37
- readonly reason: Schema.Literals<readonly ["nulByte", "absolute", "traversal", "empty", "reservedName"]>;
43
+ readonly reason: Schema.Literals<readonly ["nulByte", "absolute", "traversal", "empty", "reservedName", "outside", "unresolvable"]>;
44
+ /** The resolution failure, present when `reason` is `"unresolvable"`. */
45
+ readonly cause: Schema.optionalKey<Schema.Defect>;
38
46
  }>, import("effect/Cause").YieldableError>;
39
47
  /**
40
48
  * An untrusted relative path would leave, or does not name anything under,
@@ -82,7 +90,7 @@ export declare class PathEscapeError extends PathEscapeError_base {
82
90
  * base yields the normalised relative path alone. The check is lexical, not
83
91
  * physical, and symlinks are out of scope: a symlink already inside the
84
92
  * base can still point out of it, so a caller extracting into a directory
85
- * it does not own resolves links separately.
93
+ * it does not own checks the result with `confineReal` as well.
86
94
  *
87
95
  * {@link confineRelative} is defined in terms of this function and adds
88
96
  * only the tracing span.
@@ -139,8 +147,49 @@ export declare const confineRelative: (base: string, candidate: string) => Effec
139
147
  * @public
140
148
  */
141
149
  interface DescendOptions {
142
- /** Absolute directory the pattern is resolved against. Required — walker never reads `process.cwd()`. */
150
+ /**
151
+ * Absolute directory the pattern is resolved against. Required — walker
152
+ * never reads `process.cwd()`.
153
+ *
154
+ * @remarks
155
+ * **A missing `cwd` is an empty result, never a failure**: when `cwd` does
156
+ * not exist, is a file, or is a dangling link, every pattern — literal or
157
+ * magic — answers zero matches (`[]`, or `{ matches: [], unreadable: [] }`
158
+ * under `"record"`), in the `Effect` and the sync forms alike. Zero
159
+ * matches is a normal glob answer, so a caller that must tell "nothing
160
+ * matched" from "there was nothing to look in" checks `cwd` itself first.
161
+ * The same holds for a pattern's literal base below `cwd`. Only an
162
+ * unreadable directory the walk does reach fails, under `"fail"`.
163
+ *
164
+ * **A relative `cwd` — the empty string included — is a defect**: the
165
+ * `Effect` forms die and the sync forms throw. A relative directory would
166
+ * resolve against the process working directory, so the same call would
167
+ * walk different trees from a hook, a CLI and a test runner, and on win32
168
+ * an empty `cwd` joined with a pattern spelled `C:/x` is the drive path
169
+ * `C:\x`. Absoluteness is judged by the injected `Path` (or the sync port's
170
+ * `isAbsolute`), so win32 accepts `C:\repo` and `\\server\share`; a
171
+ * win32 `\repo` names the root of whichever drive the process is on, so
172
+ * it dies as a relative `cwd` does.
173
+ */
143
174
  readonly cwd: string;
175
+ /**
176
+ * Which entry kinds match. `"file"` (the default) answers files only, as
177
+ * `descend` always has; `"directory"` answers directories only; `"any"`
178
+ * answers both.
179
+ *
180
+ * @remarks
181
+ * The kind is the RESOLVED one — links followed, as for files — so a link
182
+ * to a directory matches as a directory, even where `followSymlinks: false`
183
+ * keeps the walk from entering it, and even when its target lies outside
184
+ * `cwd`. Check such an entry with `confineReal` before acting on it. A
185
+ * directory matches as an entry of its parent: `prune`, a boundary and an
186
+ * unentered link withhold what lies below a directory, never the
187
+ * directory itself, so a globstar pattern for `node_modules` finds every
188
+ * `node_modules` under the default `prune` without reading inside one. A directory is tested bare
189
+ * and with a trailing `/`, so the directory-only glob form `dist/`
190
+ * matches, answered as spelled. `cwd` itself is never a match.
191
+ */
192
+ readonly match?: "file" | "directory" | "any";
144
193
  /** Hard cap on directory depth below the pattern's literal prefix. Defaults to 256. */
145
194
  readonly maxDepth?: number;
146
195
  /** Directory names never descended into. Defaults to `["node_modules", ".git"]`; a custom list replaces the default. */
@@ -279,8 +328,8 @@ export declare class DescendError extends DescendError_base {
279
328
  get message(): string;
280
329
  }
281
330
  /**
282
- * One matched file with the stat the walk already read for it — `descendEntries`'
283
- * element. Costs no syscall beyond `descend`'s: the walk stats every entry to
331
+ * One matched entry — a file, or under `match` a directory — with the stat
332
+ * the walk already read for it: `descendEntries`' element. Costs no syscall beyond `descend`'s: the walk stats every entry to
284
333
  * classify it, and this keeps the answer instead of discarding it.
285
334
  *
286
335
  * The type parameter is the stat's type, so the sync walk reuses this shape
@@ -289,7 +338,7 @@ export declare class DescendError extends DescendError_base {
289
338
  * @public
290
339
  */
291
340
  interface DescendEntry<I = FileSystem.File.Info> {
292
- /** The file's path relative to `cwd`, POSIX separators. */
341
+ /** The entry's path relative to `cwd`, POSIX separators. */
293
342
  readonly path: string;
294
343
  /** `FileSystem.stat` of the file — links followed, so a symlinked file reports its target. */
295
344
  readonly info: I;
@@ -322,7 +371,8 @@ interface DescendEntriesResult<I = FileSystem.File.Info, C = PlatformError.Platf
322
371
  export declare function descend(pattern: GlobPattern, options: DescendRecordOptions): Effect.Effect<DescendResult, DescendError, FileSystem.FileSystem | Path.Path>;
323
372
  /**
324
373
  * Expand a compiled glob pattern against the filesystem, returning matching
325
- * FILE paths relative to `cwd` (POSIX separators), sorted by relative path.
374
+ * FILE paths relative to `cwd` (POSIX separators), sorted by relative path —
375
+ * or, under `match: "directory"` or `"any"`, directory paths too.
326
376
  *
327
377
  * @remarks
328
378
  * A path is answered in the spelling the pattern matched it under: `./src/*.ts`
@@ -334,8 +384,9 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
334
384
  * path is zero matches, not an error. A magic pattern walks each of its
335
385
  * `GlobPattern.enumerationRoots`, reading each directory once however many
336
386
  * alternatives spell it; a NEGATED pattern can match paths outside those
337
- * roots, so it walks from `cwd` itself. A missing
338
- * base directory is likewise an empty result, because zero matches is a
387
+ * roots, so it walks from `cwd` itself. A missing `cwd` or
388
+ * base directory (absent, a file, a dangling link) is likewise an empty
389
+ * result — see `DescendOptions.cwd` — because zero matches is a
339
390
  * normal glob answer — as is any pattern that lexically climbs above `cwd`
340
391
  * via `..` segments (walked paths never contain `..`). "Never reads outside
341
392
  * its documented root" holds lexically always, and physically only while
@@ -345,7 +396,8 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
345
396
  * (under the default `onUnreadable: "fail"`) or a walk past `maxDepth` fails,
346
397
  * typed as {@link DescendError}.
347
398
  *
348
- * Only files match. A symlink counts when it stat-resolves to a file
399
+ * Only files match by default (`match` widens that to directories). A symlink
400
+ * counts when it stat-resolves to a file
349
401
  * (`FileSystem.stat` follows links, as node's does); a symlinked directory is
350
402
  * never descended into by default (cycle safety — detected by an `lstat`
351
403
  * probe), unless `followSymlinks: true` asks for it, which follows links under
@@ -462,17 +514,30 @@ interface WalkerSyncFileSystem {
462
514
  readonly realPath: (path: string) => string;
463
515
  }
464
516
  /**
465
- * The synchronous path operation the sync walk needs — a structural subset of
466
- * `node:path`, so the module itself (or its `win32` / `posix` variant)
517
+ * The synchronous path operations the sync forms need — a structural subset
518
+ * of `node:path`, so the module itself (or its `win32` / `posix` variant)
467
519
  * satisfies it. It shapes only the absolute paths handed to the
468
520
  * {@link WalkerSyncFileSystem}; matching is POSIX-relative by the glob
469
521
  * contract.
470
522
  *
523
+ * @remarks
524
+ * The members mirror core's `Path` service, so a sync form judges
525
+ * absoluteness, ascends and compares ceilings exactly as its `Effect` twin
526
+ * does under the `Path` layer of the same platform.
527
+ *
471
528
  * @public
472
529
  */
473
530
  interface WalkerSyncPath {
531
+ /** The implementation's separator (`path.sep`). */
532
+ readonly sep: string;
474
533
  /** Join segments with the implementation's separator (like `path.join`). */
475
534
  readonly join: (...segments: ReadonlyArray<string>) => string;
535
+ /** The parent directory of a path (like `path.dirname`); a fixpoint at the root. */
536
+ readonly dirname: (path: string) => string;
537
+ /** Resolve segments to a normalized path (like `path.resolve`). */
538
+ readonly resolve: (...segments: ReadonlyArray<string>) => string;
539
+ /** Whether a path is absolute under this implementation (like `path.isAbsolute`). */
540
+ readonly isAbsolute: (path: string) => boolean;
476
541
  }
477
542
  /**
478
543
  * The platform half of every sync walk.
@@ -544,6 +609,104 @@ export declare function descendEntriesSync(pattern: GlobPattern, options: Descen
544
609
  * @public
545
610
  */
546
611
  export declare function descendEntriesSync(pattern: GlobPattern, options: DescendSyncOptions): Result.Result<ReadonlyArray<DescendEntry<WalkerSyncStats>>, DescendError>;
612
+ /**
613
+ * Confine a path to a base directory PHYSICALLY: resolve links in both, and
614
+ * succeed with the candidate's real path only when it lies strictly inside
615
+ * the base's real path.
616
+ *
617
+ * @remarks
618
+ * Reach for it where `confineRelative` stops: a link inside the base that
619
+ * points out of it (a symlinked `dist`, a planted `node_modules` entry), an
620
+ * absolute path an agent or a tool handed over, a `descend` match — a
621
+ * matched link is answered even when its target lies outside `cwd`.
622
+ *
623
+ * The candidate may be absolute (checked as it is) or relative (taken
624
+ * against `base`). It is resolved the way the operating system resolves it:
625
+ * segment by segment, so a `..` after a link climbs out of the link's
626
+ * TARGET — `base/link/../x` is outside when `link` points outside — rather
627
+ * than being collapsed lexically first, as `path.resolve` and node's JS
628
+ * `fs.realpath` would. A path, or the tail of one, that does not exist yet
629
+ * is resolved lexically from its real existing ancestor, so a write target
630
+ * can be checked before it is created — but only once absence is proved: a
631
+ * segment whose real path is not found is checked again without following
632
+ * links, because a dangling link is "not found" too, and a write through one
633
+ * creates its target wherever it points. The base is resolved the same way.
634
+ *
635
+ * Refused, typed as {@link PathEscapeError}: a NUL character (`nulByte`);
636
+ * the empty string or any path whose real location IS the base (`empty`,
637
+ * the same ruling `confineRelative` makes — a caller confining a path names
638
+ * an entry under the base); on win32 a drive-relative `C:x` or a driveless
639
+ * rooted `\x`, which names the current drive (`absolute`), or a segment
640
+ * win32 reads as something else, such as a device name or `.. `
641
+ * (`reservedName`); a real path outside the base's (`outside`); and a
642
+ * resolution that fails for a reason other than absence, such as a
643
+ * permission error, a link loop, or a dangling link — a link whose target
644
+ * does not exist, refused even when that target would lie inside the base
645
+ * (`unresolvable`, with the failure in `cause`) — containment that cannot be
646
+ * verified is refused, never assumed.
647
+ *
648
+ * A relative `base` — the empty string included — is a **defect**: it would
649
+ * be resolved against the process working directory, the silent wrong-root
650
+ * walk `ascend` refuses for a relative `stopAt`. Absoluteness is judged by
651
+ * the injected `Path`, so win32 accepts `C:\repo` and `\\server\share`;
652
+ * a win32 `\repo`, rooted on whichever drive the process is on, is a defect
653
+ * too.
654
+ *
655
+ * Use the returned path, not the candidate: it is the location that was
656
+ * checked. Comparison is exact on the real paths, so on a case-insensitive
657
+ * filesystem a base and a candidate spelled in different case may be refused
658
+ * — the check fails closed. As with any check-then-use on a live filesystem,
659
+ * a link swapped in between the check and the use is not covered.
660
+ *
661
+ * @example
662
+ * ```ts
663
+ * import { confineReal } from "@effected/walker";
664
+ * import { Effect } from "effect";
665
+ *
666
+ * const target = confineReal("/repo", "packages/app/dist").pipe(
667
+ * Effect.catchTag("PathEscapeError", (error) => Effect.die(error)),
668
+ * );
669
+ * // Requires `FileSystem` and `Path` from the platform layer.
670
+ * ```
671
+ *
672
+ * @param base - The absolute directory the candidate must stay under.
673
+ * @param candidate - The path to check: absolute, or relative to `base`.
674
+ * @returns An `Effect` succeeding with the candidate's real path, or failing
675
+ * with {@link PathEscapeError}.
676
+ *
677
+ * @public
678
+ */
679
+ export declare const confineReal: (base: string, candidate: string) => Effect.Effect<string, PathEscapeError, FileSystem.FileSystem | Path.Path>;
680
+ /**
681
+ * {@link confineReal}, synchronously, over a caller-supplied port.
682
+ *
683
+ * @remarks
684
+ * The same resolution from the same code, through the port's `realPath`; a
685
+ * `realPath` throwing `code: "ENOENT"` is absence only when the port's
686
+ * `lstat` throws `ENOENT` too (otherwise a dangling link is there); anything
687
+ * else is `unresolvable`, with the thrown value as the `cause`. A relative `base`
688
+ * throws. For a caller with no Effect runtime; inside Effect, use
689
+ * `confineReal`.
690
+ *
691
+ * @example
692
+ * ```ts
693
+ * import { confineRealSync } from "@effected/walker";
694
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
695
+ * import { Result } from "effect";
696
+ *
697
+ * const checked = confineRealSync("/repo", "/repo/packages/app/dist", nodeWalkerSyncOps);
698
+ * if (Result.isFailure(checked)) console.error(checked.failure.message);
699
+ * ```
700
+ *
701
+ * @param base - The absolute directory the candidate must stay under.
702
+ * @param candidate - The path to check: absolute, or relative to `base`.
703
+ * @param ops - The platform: spread or pass `nodeWalkerSyncOps`.
704
+ * @returns A `Result` succeeding with the candidate's real path, or failing
705
+ * with {@link PathEscapeError}.
706
+ *
707
+ * @public
708
+ */
709
+ export declare const confineRealSync: (base: string, candidate: string, ops: WalkerSyncOps) => Result.Result<string, PathEscapeError>;
547
710
  /**
548
711
  * Options for {@link compileAndExpand}: every {@link DescendOptions} field,
549
712
  * plus the glob options the pattern compiles under.
@@ -684,7 +847,8 @@ interface AscendOptions {
684
847
  *
685
848
  * Only the CEILING is constrained — a relative `start` is fine and ascends
686
849
  * to the relative root. Absoluteness is judged by the injected `Path`
687
- * service, so the win32 layer accepts `C:\repo` and posix does not.
850
+ * service, so the win32 layer accepts `C:\repo` and posix does not; a
851
+ * win32 `\repo`, rooted on whichever drive the process is on, dies too.
688
852
  *
689
853
  * Normalization governs the comparison only: the chain `ascend` returns is
690
854
  * still the lexical one derived from `start`, unrewritten. A ceiling naming
@@ -700,6 +864,37 @@ interface AscendOptions {
700
864
  /** Hard cap on chain length. Defaults to 256. */
701
865
  readonly maxDepth?: number;
702
866
  }
867
+ /**
868
+ * Options for {@link Walker.ascendSync}: every {@link AscendOptions} field,
869
+ * plus the path implementation the chain is computed with.
870
+ *
871
+ * @public
872
+ */
873
+ interface AscendSyncOptions extends AscendOptions {
874
+ /** Path arithmetic — `node:path` satisfies it (`nodeWalkerPath` from `@effected/walker/node-sync`). */
875
+ readonly path: WalkerSyncPath;
876
+ }
877
+ /**
878
+ * Options for the synchronous upward forms that touch the filesystem —
879
+ * {@link Walker.ascendWithinSync} and {@link Walker.ascendToMarkerSync}: the
880
+ * depth cap, plus the platform (spread `nodeWalkerSyncOps` from
881
+ * `@effected/walker/node-sync`).
882
+ *
883
+ * @public
884
+ */
885
+ interface AscendWithinSyncOptions extends Pick<AscendOptions, "maxDepth">, WalkerSyncOps {}
886
+ /**
887
+ * {@link Walker.findUpwardAll}'s answer: the nearest directory holding any
888
+ * candidate, and every candidate that exists there.
889
+ *
890
+ * @public
891
+ */
892
+ interface UpwardMatches {
893
+ /** The directory whose candidates produced the hits. */
894
+ readonly dir: string;
895
+ /** Every existing candidate of `dir`, in the order `candidatesFor` gave them. Never empty. */
896
+ readonly found: readonly [string, ...Array<string>];
897
+ }
703
898
  /**
704
899
  * Upward path traversal primitives: the directory chain from a start path to the
705
900
  * root, and first-match scans over it.
@@ -804,6 +999,171 @@ export declare class Walker {
804
999
  * `pnpm-workspace.yaml`), and its failures are absorbed per directory.
805
1000
  */
806
1001
  static findRoot<E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>): Effect.Effect<Option.Option<string>, never, R>;
1002
+ /**
1003
+ * {@link Walker.ascend} stopped at the nearest directory that holds any of
1004
+ * `markers`, inclusive: the chain from `start` up to and including the
1005
+ * first directory with a `.git` (or whatever the markers name) entry.
1006
+ *
1007
+ * @remarks
1008
+ * A marker is an entry NAME probed with `FileSystem.exists` in each
1009
+ * directory, nearest first, so any entry kind counts — a `.git` directory,
1010
+ * or the `.git` FILE of a worktree or submodule — and a link counts when it
1011
+ * resolves. The probes stop at the hit: nothing above the directory that
1012
+ * holds a marker is ever touched. With no marker anywhere the chain runs to
1013
+ * the filesystem root, exactly as `ascend(start)` gives it.
1014
+ *
1015
+ * A failed probe is absorbed as "no marker here", as
1016
+ * {@link Walker.firstMatch} absorbs one, so one unreadable directory never
1017
+ * hides a marker above it. An empty `markers` list, or an empty marker
1018
+ * name, is a **defect**: the first would silently ask for the unbounded
1019
+ * walk and the second would match every directory, and both are
1020
+ * statically wrong at the call site. An invalid `maxDepth` dies exactly as
1021
+ * `ascend`'s does.
1022
+ *
1023
+ * There is no `stopAt` here: a marker stop and a lexical ceiling answer the
1024
+ * same question two ways. To bound a marker walk by a ceiling as well,
1025
+ * take the shorter of the two chains.
1026
+ *
1027
+ * @example
1028
+ * ```ts
1029
+ * import { Walker } from "@effected/walker";
1030
+ * import { Effect } from "effect";
1031
+ *
1032
+ * const program = Effect.gen(function* () {
1033
+ * // With `.git` in /repo: ["/repo/packages/app", "/repo/packages", "/repo"]
1034
+ * return yield* Walker.ascendToMarker("/repo/packages/app", [".git"]);
1035
+ * });
1036
+ * // Requires `FileSystem` and `Path` from the platform layer.
1037
+ * ```
1038
+ */
1039
+ static ascendToMarker(start: string, markers: ReadonlyArray<string>, options?: Pick<AscendOptions, "maxDepth">): Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path>;
1040
+ /**
1041
+ * Every existing candidate of the NEAREST directory in `dirs` that has any,
1042
+ * or `Option.none()` — {@link Walker.findUpward} for a caller that must see
1043
+ * a second hit (an ambiguity) rather than have the first one chosen.
1044
+ *
1045
+ * @remarks
1046
+ * Directories are scanned in order, and every candidate of a directory is
1047
+ * probed before the scan ascends; the scan stops after the first directory
1048
+ * with a hit, so nothing above it is probed. `found` keeps the order
1049
+ * `candidatesFor` produced and is never empty. A failed probe is absorbed
1050
+ * as "not there", as in `findUpward`, so not-found and cannot-look are
1051
+ * indistinguishable here too.
1052
+ *
1053
+ * @example
1054
+ * ```ts
1055
+ * import { Walker } from "@effected/walker";
1056
+ * import { Effect, Option } from "effect";
1057
+ *
1058
+ * const program = Effect.gen(function* () {
1059
+ * const dirs = yield* Walker.ascend("/repo/packages/app");
1060
+ * const hits = yield* Walker.findUpwardAll(dirs, (dir) => [`${dir}/tool.config.ts`, `${dir}/tool.config.js`]);
1061
+ * // Two config files in one directory is the caller's ambiguity to report.
1062
+ * return Option.map(hits, ({ found }) => found.length > 1);
1063
+ * });
1064
+ * ```
1065
+ */
1066
+ static findUpwardAll(dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>): Effect.Effect<Option.Option<UpwardMatches>, never, FileSystem.FileSystem>;
1067
+ /**
1068
+ * {@link Walker.ascend}, synchronously, over a caller-supplied path
1069
+ * implementation.
1070
+ *
1071
+ * @remarks
1072
+ * The same chain from the same code: lexical, nearest first, `stopAt`
1073
+ * inclusive and compared in resolved form. A relative `stopAt` or an
1074
+ * invalid `maxDepth` is a defect, which here THROWS, since there is no
1075
+ * error channel to die through. For a caller with no Effect runtime (a CLI
1076
+ * helper that runs before one, a bundler-plugin hook); inside Effect, use
1077
+ * `ascend`.
1078
+ *
1079
+ * @example
1080
+ * ```ts
1081
+ * import { Walker } from "@effected/walker";
1082
+ * import { nodeWalkerPath } from "@effected/walker/node-sync";
1083
+ *
1084
+ * const dirs = Walker.ascendSync("/repo/packages/app", { stopAt: "/repo", path: nodeWalkerPath });
1085
+ * // => ["/repo/packages/app", "/repo/packages", "/repo"]
1086
+ * ```
1087
+ */
1088
+ static ascendSync(start: string, options: AscendSyncOptions): ReadonlyArray<string>;
1089
+ /**
1090
+ * {@link Walker.ascendWithin}, synchronously, over a caller-supplied port:
1091
+ * the lexical chain stopped at the nearest directory whose `realPath` is
1092
+ * the ceiling's.
1093
+ *
1094
+ * @remarks
1095
+ * The same walk from the same code as `ascendWithin`: `Option.none()` is
1096
+ * exactly `ascendSync(start)`, a ceiling the chain already spells costs no
1097
+ * `realPath`, a `realPath` that throws is absorbed as "not the ceiling",
1098
+ * and an unresolvable ceiling leaves the lexical chain standing. A
1099
+ * relative ceiling or an invalid `maxDepth` throws.
1100
+ *
1101
+ * @example
1102
+ * ```ts
1103
+ * import { Walker } from "@effected/walker";
1104
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
1105
+ * import { Option } from "effect";
1106
+ *
1107
+ * const dirs = Walker.ascendWithinSync("/repo/packages/app", Option.some("/repo"), nodeWalkerSyncOps);
1108
+ * ```
1109
+ */
1110
+ static ascendWithinSync(start: string, ceiling: Option.Option<string>, options: AscendWithinSyncOptions): ReadonlyArray<string>;
1111
+ /**
1112
+ * {@link Walker.ascendToMarker}, synchronously, over a caller-supplied port.
1113
+ *
1114
+ * @remarks
1115
+ * The same walk from the same code. A marker exists when the port's `stat`
1116
+ * (links followed) answers for it; a `stat` that throws, for any reason,
1117
+ * is "no marker here", as the `Effect` form absorbs a failed probe. An
1118
+ * empty `markers` list, an empty marker name or an invalid `maxDepth`
1119
+ * throws.
1120
+ *
1121
+ * @example
1122
+ * ```ts
1123
+ * import { Walker } from "@effected/walker";
1124
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
1125
+ *
1126
+ * const dirs = Walker.ascendToMarkerSync("/repo/packages/app", [".git"], nodeWalkerSyncOps);
1127
+ * ```
1128
+ */
1129
+ static ascendToMarkerSync(start: string, markers: ReadonlyArray<string>, options: AscendWithinSyncOptions): ReadonlyArray<string>;
1130
+ /**
1131
+ * {@link Walker.findUpward}, synchronously, over a caller-supplied port: the
1132
+ * first existing candidate, directory-major, or `Option.none()`.
1133
+ *
1134
+ * @remarks
1135
+ * The same scan from the same code. A candidate exists when the port's
1136
+ * `stat` (links followed) answers for it; a `stat` that throws is "not
1137
+ * there". A throwing `candidatesFor` throws before any probe.
1138
+ *
1139
+ * @example
1140
+ * ```ts
1141
+ * import { Walker } from "@effected/walker";
1142
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
1143
+ *
1144
+ * const dirs = Walker.ascendSync("/repo/packages/app", { path: nodeWalkerSyncOps.path });
1145
+ * const workspace = Walker.findUpwardSync(dirs, (dir) => [`${dir}/pnpm-workspace.yaml`], nodeWalkerSyncOps);
1146
+ * ```
1147
+ */
1148
+ static findUpwardSync(dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>, ops: WalkerSyncOps): Option.Option<string>;
1149
+ /**
1150
+ * {@link Walker.findUpwardAll}, synchronously, over a caller-supplied port.
1151
+ *
1152
+ * @remarks
1153
+ * The same scan from the same code: every existing candidate of the
1154
+ * nearest directory with any, in candidate order, and nothing above it
1155
+ * probed. A candidate exists when the port's `stat` answers for it.
1156
+ *
1157
+ * @example
1158
+ * ```ts
1159
+ * import { Walker } from "@effected/walker";
1160
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
1161
+ *
1162
+ * const dirs = Walker.ascendSync("/repo/packages/app", { path: nodeWalkerSyncOps.path });
1163
+ * const hits = Walker.findUpwardAllSync(dirs, (dir) => [`${dir}/a.config.ts`, `${dir}/a.config.js`], nodeWalkerSyncOps);
1164
+ * ```
1165
+ */
1166
+ static findUpwardAllSync(dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>, ops: WalkerSyncOps): Option.Option<UpwardMatches>;
807
1167
  }
808
- export type { AscendOptions, CompileAndExpandOptions, DescendEntriesResult, DescendEntry, DescendOptions, DescendRecordOptions, DescendResult, DescendSyncOptions, DescendSyncRecordOptions, UnreadableDirectory, WalkerSyncFileSystem, WalkerSyncOps, WalkerSyncPath, WalkerSyncStats };
1168
+ export type { AscendOptions, AscendSyncOptions, AscendWithinSyncOptions, CompileAndExpandOptions, DescendEntriesResult, DescendEntry, DescendOptions, DescendRecordOptions, DescendResult, DescendSyncOptions, DescendSyncRecordOptions, UnreadableDirectory, UpwardMatches, WalkerSyncFileSystem, WalkerSyncOps, WalkerSyncPath, WalkerSyncStats };
809
1169
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { PathEscapeError, PathEscapeReason, confineRelative, confineRelativeResult } from "./Confine.js";
2
+ import { confineReal, confineRealSync } from "./ConfineReal.js";
2
3
  import { DescendError, descend, descendEntries } from "./Descend.js";
3
4
  import { descendEntriesSync, descendSync } from "./DescendSync.js";
4
5
  import { GlobExpansionError, compileAndExpand } from "./Expand.js";
5
6
  import { Walker } from "./Walker.js";
6
7
 
7
- export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineRelative, confineRelativeResult, descend, descendEntries, descendEntriesSync, descendSync };
8
+ export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineReal, confineRealSync, confineRelative, confineRelativeResult, descend, descendEntries, descendEntriesSync, descendSync };
@@ -0,0 +1,19 @@
1
+ // src/internal/absolute.ts
2
+ /**
3
+ * A win32 path rooted on the CURRENT drive: one leading separator, not two
4
+ * (`\\server\share` and `\\?\C:\x` are UNC and device paths, which name their
5
+ * root). `path.win32.isAbsolute` calls `\repo` absolute, yet which drive it
6
+ * names depends on the process working directory — exactly like the
7
+ * drive-relative `C:x`.
8
+ */
9
+ const CURRENT_DRIVE_ROOT = /^[\\/](?![\\/])/;
10
+ /** Whether `path` is rooted on the current drive under win32 path rules. */
11
+ const isCurrentDriveRooted = (path, sep) => sep === "\\" && CURRENT_DRIVE_ROOT.test(path);
12
+ /**
13
+ * Whether `path` names one location whatever the process working directory:
14
+ * absolute to the injected implementation, and on win32 not rooted on the
15
+ * current drive.
16
+ */
17
+ const isPinnedAbsolute = (path, ops) => ops.isAbsolute(path) && !isCurrentDriveRooted(path, ops.sep);
18
+
19
+ export { isCurrentDriveRooted, isPinnedAbsolute };
@@ -1,5 +1,6 @@
1
1
  import { resolveSegments } from "./segments.js";
2
2
  import { trimTrailing } from "./trimTrailing.js";
3
+ import { isPinnedAbsolute } from "./absolute.js";
3
4
 
4
5
  // src/internal/descendMachine.ts
5
6
  /** Directory names never descended into unless the caller overrides `prune`. */
@@ -9,7 +10,8 @@ const isSlash = (character) => character === "/";
9
10
  /** Shared empty ancestor chain: frames under `followSymlinks: false` never consult theirs. */
10
11
  const NO_ANCESTORS = Object.freeze([]);
11
12
  /** The walk. */
12
- function* descendMachine(pattern, options, join) {
13
+ function* descendMachine(pattern, options, path) {
14
+ const join = path.join;
13
15
  /** Yields one request; the answer is typed by the request's tag. */
14
16
  function* ask(request) {
15
17
  return yield request;
@@ -19,6 +21,19 @@ function* descendMachine(pattern, options, join) {
19
21
  _tag: "Defect",
20
22
  message: `Walker.descend: maxDepth must be a positive integer, received ${maxDepth}`
21
23
  };
24
+ if (options.cwd.includes("\0") || !isPinnedAbsolute(options.cwd, path)) return {
25
+ _tag: "Defect",
26
+ message: `Walker.descend: cwd must be an absolute path, received ${JSON.stringify(options.cwd)}`
27
+ };
28
+ const match = options.match ?? "file";
29
+ if (match !== "file" && match !== "directory" && match !== "any") return {
30
+ _tag: "Defect",
31
+ message: `Walker.descend: match must be "file", "directory" or "any", received ${JSON.stringify(match)}`
32
+ };
33
+ const matchFiles = match !== "directory";
34
+ const matchDirectories = match !== "file";
35
+ /** Canonical paths of the directories already answered: a root's base may also be an entry of another root. */
36
+ const answeredDirectories = /* @__PURE__ */ new Set();
22
37
  const prune = new Set(options.prune ?? DEFAULT_PRUNE);
23
38
  const boundaries = options.boundaries ?? [];
24
39
  const onUnreadable = options.onUnreadable ?? "fail";
@@ -93,7 +108,8 @@ function* descendMachine(pattern, options, join) {
93
108
  _tag: "Stat",
94
109
  path: join(options.cwd, pattern.source)
95
110
  });
96
- return done(answer?.kind === "File" ? [{
111
+ const wanted = answer?.kind === "File" && matchFiles || answer?.kind === "Directory" && matchDirectories && segments.length > 0;
112
+ return done(answer !== void 0 && wanted ? [{
97
113
  path: pattern.source,
98
114
  info: answer.info
99
115
  }] : []);
@@ -122,7 +138,7 @@ function* descendMachine(pattern, options, join) {
122
138
  canonical: canonical === "" ? "" : `${canonical}/`
123
139
  };
124
140
  });
125
- /** The file at canonical `relative`, in the first spelling the pattern matches it under, or `undefined`. */
141
+ /** The entry at canonical `relative`, in the first spelling the pattern matches it under, or `undefined`. */
126
142
  const matchedAs = (relative) => {
127
143
  for (const { spelling, canonical } of spelled) {
128
144
  if (canonical !== "" && !relative.startsWith(canonical)) continue;
@@ -130,6 +146,17 @@ function* descendMachine(pattern, options, join) {
130
146
  if (pattern.matches(candidate)) return candidate;
131
147
  }
132
148
  };
149
+ /**
150
+ * The directory at canonical `relative`, in the first spelling the pattern
151
+ * matches it under — bare, or with a trailing `/` (a glob's directory-only
152
+ * form) — or `undefined`. Each directory is answered once.
153
+ */
154
+ const matchedDirectoryAs = (relative) => {
155
+ if (answeredDirectories.has(relative)) return void 0;
156
+ const answer = matchedAs(relative) ?? matchedAs(`${relative}/`);
157
+ if (answer !== void 0) answeredDirectories.add(relative);
158
+ return answer;
159
+ };
133
160
  const baseSegments = resolveSegments(rawBase.split("/"));
134
161
  if (baseSegments === void 0) return void 0;
135
162
  const base = baseSegments.join("/");
@@ -137,10 +164,11 @@ function* descendMachine(pattern, options, join) {
137
164
  const own = spelled.find(({ canonical }) => canonical === baseCanonical)?.spelling ?? baseCanonical;
138
165
  const shown = (relative) => relative === base ? trimTrailing(own, isSlash) : own + relative.slice(baseCanonical.length);
139
166
  const absoluteBase = base === "" ? options.cwd : join(options.cwd, base);
140
- if ((yield* ask({
167
+ const baseStat = yield* ask({
141
168
  _tag: "Stat",
142
169
  path: absoluteBase
143
- }))?.kind !== "Directory") return void 0;
170
+ });
171
+ if (baseStat?.kind !== "Directory") return void 0;
144
172
  if (yield* blocked(baseSegments, baseSegments.length, baseSegments.length - 1)) return void 0;
145
173
  let baseAncestors = NO_ANCESTORS;
146
174
  if (followSymlinks) {
@@ -149,6 +177,13 @@ function* descendMachine(pattern, options, join) {
149
177
  if (real === void 0) return void 0;
150
178
  baseAncestors = [real];
151
179
  }
180
+ if (matchDirectories && base !== "") {
181
+ const answer = matchedDirectoryAs(base);
182
+ if (answer !== void 0) results.push({
183
+ path: answer,
184
+ info: baseStat.info
185
+ });
186
+ }
152
187
  const frames = [{
153
188
  relative: base,
154
189
  absolute: absoluteBase,
@@ -186,13 +221,20 @@ function* descendMachine(pattern, options, join) {
186
221
  }) : own;
187
222
  if (stat === void 0) continue;
188
223
  if (stat.kind === "File") {
189
- const path = matchedAs(relative);
190
- if (path !== void 0) results.push({
191
- path,
224
+ const answer = matchFiles ? matchedAs(relative) : void 0;
225
+ if (answer !== void 0) results.push({
226
+ path: answer,
192
227
  info: stat.info
193
228
  });
194
229
  continue;
195
230
  }
231
+ if (stat.kind === "Directory" && matchDirectories) {
232
+ const answer = matchedDirectoryAs(relative);
233
+ if (answer !== void 0) results.push({
234
+ path: answer,
235
+ info: stat.info
236
+ });
237
+ }
196
238
  if (stat.kind !== "Directory" || !deep) continue;
197
239
  if (prune.has(entry)) continue;
198
240
  let ancestors = frame.ancestors;