@effected/walker 0.20.0 → 0.21.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/Confine.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveSegments } from "./internal/segments.js";
2
+ import { trimTrailing } from "./internal/trimTrailing.js";
2
3
  import { Effect, Result, Schema } from "effect";
3
4
 
4
5
  // src/Confine.ts
@@ -71,12 +72,8 @@ const DOTS_AND_SPACES = /^[. ]+$/;
71
72
  const DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3]|conin\$|conout\$) *(?:\..*)?$/i;
72
73
  /** Whether win32 reads a normalised segment as something other than a plain entry. */
73
74
  const isReservedSegment = (segment) => segment.includes(":") || DOTS_AND_SPACES.test(segment) || DEVICE_NAME.test(segment);
74
- /** `path` without trailing `/` or `\`: a loop, because `/[\\/]+$/` backtracks quadratically on a long separator run. */
75
- const stripTrailingSeparators = (path) => {
76
- let end = path.length;
77
- while (end > 0 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
78
- return path.slice(0, end);
79
- };
75
+ /** `path` without trailing `/` or `\`, by a loop: `/[\\/]+$/` backtracks quadratically on a long separator run. */
76
+ const stripTrailingSeparators = (path) => trimTrailing(path, (character) => character === "/" || character === "\\");
80
77
  /**
81
78
  * Confine an untrusted relative path to a base directory, synchronously.
82
79
  *
package/Descend.js CHANGED
@@ -1,4 +1,4 @@
1
- import { resolveSegments } from "./internal/segments.js";
1
+ import { descendMachine, failureOf } from "./internal/descendMachine.js";
2
2
  import { Effect, FileSystem, Path, Schema } from "effect";
3
3
 
4
4
  // src/Descend.ts
@@ -25,140 +25,70 @@ var DescendError = class extends Schema.TaggedError()("DescendError", {
25
25
  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}`;
26
26
  }
27
27
  };
28
- /** Directory names never descended into unless the caller overrides `prune`. */
29
- const DEFAULT_PRUNE = ["node_modules", ".git"];
28
+ const kindOf = (info) => info.type === "File" ? "File" : info.type === "Directory" ? "Directory" : "Other";
30
29
  /**
31
- * Whether a cwd-relative pattern path lexically climbs above `cwd` via `..`
32
- * segments. Walked paths never contain `..`, so such a pattern can never match
33
- * one — the answer is zero matches, and no filesystem access outside `cwd`
34
- * ever happens (a pattern must not enumerate the tree above its documented
35
- * root).
30
+ * The `Effect` driver of the shared walk (`internal/descendMachine.ts`): each
31
+ * request the walk yields is answered through core's `FileSystem`, joins
32
+ * through `Path`. Every rule lives in the machine; the sync driver in
33
+ * `DescendSync.ts` answers the same requests from a synchronous port.
36
34
  */
37
- const escapesCwd = (relative) => resolveSegments(relative.split("/")) === void 0;
38
- /** Shared empty ancestor chain: frames under `followSymlinks: false` never consult theirs. */
39
- const NO_ANCESTORS = Object.freeze([]);
40
- /** Not exported — `descend`'s overloads below carry the public documentation. */
41
- const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
35
+ const walk = (pattern, options) => Effect.gen(function* () {
42
36
  const fs = yield* FileSystem.FileSystem;
43
37
  const path = yield* Path.Path;
44
- const maxDepth = options.maxDepth ?? 256;
45
- if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.descend: maxDepth must be a positive integer, received ${maxDepth}`));
46
- const prune = new Set(options.prune ?? DEFAULT_PRUNE);
47
- const onUnreadable = options.onUnreadable ?? "fail";
48
- const followSymlinks = options.followSymlinks ?? false;
49
- const unreadable = [];
50
- /** The stat-resolved type of `absolute`, or `undefined` when it does not resolve (missing, dangling symlink, unstatable). */
51
- const typeOf = (absolute) => fs.stat(absolute).pipe(Effect.map((info) => info.type), Effect.orElseSucceed(() => void 0));
52
- /** Whether `absolute` is itself a symlink. `readLink` succeeds only on links; any failure means "not one". */
53
- const isSymbolicLink = (absolute) => fs.readLink(absolute).pipe(Effect.map(() => true), Effect.orElseSucceed(() => false));
54
- /**
55
- * The real path of a directory the cycle guard must identify — the walk
56
- * base, or a symlinked directory — or `undefined` when the walk must not
57
- * enter it. A link the resolver cannot resolve is one the guard cannot
58
- * reason about: recording a stand-in (the link's own path) would make
59
- * every hop around a loop look unseen, so the subtree is never queued.
60
- * Whether the caller HEARS about it follows `onUnreadable` exactly as a
61
- * failed `readDirectory` does: a `NotFound` is the benign vanished-target
62
- * race (the subtree would have read as empty anyway) and stays silent in
63
- * every mode; anything else — `EACCES` on a path component, above all —
64
- * is a subtree this walk was asked to enumerate and cannot, so the
65
- * default fails typed, `"record"` keeps the path and the cause, and
66
- * `"skip"` forgets it. A silent skip here would hand a caller a smaller
67
- * answer with nothing reporting it, which is the failure `"fail"` exists
68
- * to prevent.
69
- */
70
- const realPathOf = (absolute, relative) => fs.realPath(absolute).pipe(Effect.map((real) => real), Effect.catch((error) => {
71
- if (error.reason._tag === "NotFound") return Effect.succeed(void 0);
72
- if (onUnreadable === "fail") return Effect.fail(new DescendError({
73
- pattern: pattern.source,
74
- reason: "unreadableDirectory",
75
- path: relative
76
- }));
77
- if (onUnreadable === "record") unreadable.push({
78
- path: relative,
79
- cause: error
80
- });
81
- return Effect.succeed(void 0);
82
- }));
83
- /** Wrap the walk's success value per `onUnreadable`: a plain array unless "record" asked for the pair. */
84
- const finish = (matches) => onUnreadable === "record" ? {
38
+ const read = (effect) => effect.pipe(Effect.map((value) => ({
39
+ _tag: "Ok",
40
+ value
41
+ })), Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? { _tag: "NotFound" } : {
42
+ _tag: "Failed",
43
+ cause: error
44
+ })));
45
+ const answer = (request) => {
46
+ switch (request._tag) {
47
+ case "Stat": return fs.stat(request.path).pipe(Effect.map((info) => ({
48
+ kind: kindOf(info),
49
+ info
50
+ })), Effect.orElseSucceed(() => void 0));
51
+ case "Lstat": return fs.lstat(request.path).pipe(Effect.map((info) => info.type === "SymbolicLink" ? { kind: "SymbolicLink" } : {
52
+ kind: kindOf(info),
53
+ info
54
+ }), Effect.orElseSucceed(() => void 0));
55
+ case "RealPath": return read(fs.realPath(request.path));
56
+ case "ReadDirectory": return read(fs.readDirectory(request.path));
57
+ }
58
+ };
59
+ const machine = descendMachine(pattern, options, (a, b) => path.join(a, b));
60
+ let step = machine.next();
61
+ while (step.done !== true) step = machine.next(yield* answer(step.value));
62
+ const result = step.value;
63
+ if (result._tag === "Defect") return yield* Effect.die(new Error(result.message));
64
+ if (result._tag === "Fail") return yield* new DescendError(failureOf(pattern, result));
65
+ return {
66
+ entries: result.entries,
67
+ unreadable: result.unreadable
68
+ };
69
+ });
70
+ /** Not exported — `descend`'s overloads below carry the public documentation. */
71
+ const descendImpl = Effect.fn("Walker.descend")(function* (pattern, options) {
72
+ const { entries, unreadable } = yield* walk(pattern, options);
73
+ const matches = entries.map((entry) => entry.path);
74
+ return options.onUnreadable === "record" ? {
85
75
  matches,
86
76
  unreadable
87
77
  } : matches;
88
- if (!pattern.hasMagic && !pattern.negated) {
89
- if (escapesCwd(pattern.source)) return finish([]);
90
- return finish((yield* typeOf(path.join(options.cwd, pattern.source))) === "File" ? [pattern.source] : []);
91
- }
92
- const base = pattern.negated ? "" : pattern.enumerationPrefix.replace(/\/+$/, "");
93
- if (escapesCwd(base)) return finish([]);
94
- const absoluteBase = base === "" ? options.cwd : path.join(options.cwd, base);
95
- if ((yield* typeOf(absoluteBase)) !== "Directory") return finish([]);
96
- let baseAncestors = NO_ANCESTORS;
97
- if (followSymlinks) {
98
- const real = yield* realPathOf(absoluteBase, base);
99
- if (real === void 0) return finish([]);
100
- baseAncestors = [real];
101
- }
102
- const deep = pattern.crossesSegments || pattern.negated;
103
- const results = [];
104
- const frames = [{
105
- relative: base,
106
- absolute: absoluteBase,
107
- depth: 0,
108
- ancestors: baseAncestors
109
- }];
110
- for (let head = 0; head < frames.length; head += 1) {
111
- const frame = frames[head];
112
- if (frame === void 0) break;
113
- const entries = yield* fs.readDirectory(frame.absolute).pipe(Effect.catch((error) => {
114
- if (error.reason._tag === "NotFound") return Effect.succeed([]);
115
- if (onUnreadable === "fail") return Effect.fail(new DescendError({
116
- pattern: pattern.source,
117
- reason: "unreadableDirectory",
118
- path: frame.relative
119
- }));
120
- if (onUnreadable === "record") unreadable.push({
121
- path: frame.relative,
122
- cause: error
123
- });
124
- return Effect.succeed([]);
125
- }));
126
- for (const entry of entries) {
127
- const relative = frame.relative === "" ? entry : `${frame.relative}/${entry}`;
128
- const absolute = path.join(frame.absolute, entry);
129
- const kind = yield* typeOf(absolute);
130
- if (kind === "File") {
131
- if (pattern.matches(relative)) results.push(relative);
132
- continue;
133
- }
134
- if (kind !== "Directory" || !deep) continue;
135
- if (prune.has(entry)) continue;
136
- let ancestors = frame.ancestors;
137
- if (followSymlinks) {
138
- const parentReal = frame.ancestors[frame.ancestors.length - 1] ?? frame.absolute;
139
- const real = (yield* isSymbolicLink(absolute)) ? yield* realPathOf(absolute, relative) : path.join(parentReal, entry);
140
- if (real === void 0 || frame.ancestors.includes(real)) continue;
141
- ancestors = [...frame.ancestors, real];
142
- } else if (yield* isSymbolicLink(absolute)) continue;
143
- if (frame.depth + 1 > maxDepth) return yield* new DescendError({
144
- pattern: pattern.source,
145
- reason: "depthExceeded",
146
- path: frame.relative,
147
- limit: maxDepth
148
- });
149
- frames.push({
150
- relative,
151
- absolute,
152
- depth: frame.depth + 1,
153
- ancestors
154
- });
155
- }
156
- }
157
- results.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
158
- return finish(results);
78
+ });
79
+ /** Not exported — `descendEntries`' overloads below carry the public documentation. */
80
+ const descendEntriesImpl = Effect.fn("Walker.descendEntries")(function* (pattern, options) {
81
+ const { entries, unreadable } = yield* walk(pattern, options);
82
+ return options.onUnreadable === "record" ? {
83
+ matches: entries,
84
+ unreadable
85
+ } : entries;
159
86
  });
160
87
  function descend(pattern, options) {
161
88
  return descendImpl(pattern, options);
162
89
  }
90
+ function descendEntries(pattern, options) {
91
+ return descendEntriesImpl(pattern, options);
92
+ }
163
93
 
164
- export { DescendError, descend };
94
+ export { DescendError, descend, descendEntries };
package/DescendSync.js ADDED
@@ -0,0 +1,75 @@
1
+ import { descendMachine, failureOf } from "./internal/descendMachine.js";
2
+ import { DescendError } from "./Descend.js";
3
+ import { Result } from "effect";
4
+
5
+ // src/DescendSync.ts
6
+ const isNotFound = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
7
+ const kindOf = (stats) => stats.isFile() ? "File" : stats.isDirectory() ? "Directory" : "Other";
8
+ /** The sync driver of the shared walk. Throws only for a defect (an invalid `maxDepth`). */
9
+ const walkSync = (pattern, options) => {
10
+ const { fileSystem, path } = options;
11
+ const read = (f) => {
12
+ try {
13
+ return {
14
+ _tag: "Ok",
15
+ value: f()
16
+ };
17
+ } catch (cause) {
18
+ return isNotFound(cause) ? { _tag: "NotFound" } : {
19
+ _tag: "Failed",
20
+ cause
21
+ };
22
+ }
23
+ };
24
+ const answer = (request) => {
25
+ switch (request._tag) {
26
+ case "Stat": try {
27
+ const info = fileSystem.stat(request.path);
28
+ return {
29
+ kind: kindOf(info),
30
+ info
31
+ };
32
+ } catch {
33
+ return;
34
+ }
35
+ case "Lstat": try {
36
+ const info = fileSystem.lstat(request.path);
37
+ return info.isSymbolicLink() ? { kind: "SymbolicLink" } : {
38
+ kind: kindOf(info),
39
+ info
40
+ };
41
+ } catch {
42
+ return;
43
+ }
44
+ case "RealPath": return read(() => fileSystem.realPath(request.path));
45
+ case "ReadDirectory": return read(() => fileSystem.readDirectory(request.path));
46
+ }
47
+ };
48
+ const machine = descendMachine(pattern, options, (a, b) => path.join(a, b));
49
+ let step = machine.next();
50
+ while (step.done !== true) step = machine.next(answer(step.value));
51
+ const result = step.value;
52
+ if (result._tag === "Defect") throw new Error(result.message);
53
+ if (result._tag === "Fail") return Result.fail(new DescendError(failureOf(pattern, result)));
54
+ return Result.succeed({
55
+ entries: result.entries,
56
+ unreadable: result.unreadable
57
+ });
58
+ };
59
+ function descendSync(pattern, options) {
60
+ return Result.map(walkSync(pattern, options), ({ entries, unreadable }) => {
61
+ const matches = entries.map((entry) => entry.path);
62
+ return options.onUnreadable === "record" ? {
63
+ matches,
64
+ unreadable
65
+ } : matches;
66
+ });
67
+ }
68
+ function descendEntriesSync(pattern, options) {
69
+ return Result.map(walkSync(pattern, options), ({ entries, unreadable }) => options.onUnreadable === "record" ? {
70
+ matches: entries,
71
+ unreadable
72
+ } : entries);
73
+ }
74
+
75
+ export { descendEntriesSync, descendSync };
package/Walker.js CHANGED
@@ -1,32 +1,11 @@
1
1
  import { Effect, FileSystem, Option, Path } from "effect";
2
2
 
3
3
  // src/Walker.ts
4
- const ascend = (start, options) => Effect.gen(function* () {
5
- const path = yield* Path.Path;
6
- const maxDepth = options?.maxDepth ?? 256;
7
- if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: maxDepth must be a positive integer, received ${maxDepth}`));
8
- if (options?.stopAt !== void 0 && !path.isAbsolute(options.stopAt)) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: stopAt must be an absolute path, received ${JSON.stringify(options.stopAt)} (ascending from ${JSON.stringify(start)})`));
9
- const ceiling = options?.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
10
- const dirs = [];
11
- let current = start;
12
- for (let depth = 0; depth < maxDepth; depth++) {
13
- dirs.push(current);
14
- if (ceiling !== void 0 && (current === ceiling || path.resolve(current) === ceiling)) break;
15
- const parent = path.dirname(current);
16
- if (parent === current) break;
17
- current = parent;
18
- }
19
- return dirs;
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
4
  const ascendToPhysical = (start, ceiling, options) => Effect.gen(function* () {
26
5
  const fs = yield* FileSystem.FileSystem;
27
6
  const path = yield* Path.Path;
28
7
  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, {
8
+ const dirs = yield* Walker.ascend(start, {
30
9
  ...options,
31
10
  stopAt: ceiling
32
11
  });
@@ -34,27 +13,16 @@ const ascendToPhysical = (start, ceiling, options) => Effect.gen(function* () {
34
13
  if (last !== void 0 && path.resolve(last) === path.resolve(ceiling)) return dirs;
35
14
  const physical = yield* Effect.option(fs.realPath(ceiling));
36
15
  if (Option.isNone(physical)) return dirs;
37
- const hit = yield* findRoot(dirs, (dir) => Effect.map(fs.realPath(dir), (real) => real === physical.value));
16
+ const hit = yield* Walker.findRoot(dirs, (dir) => Effect.map(fs.realPath(dir), (real) => real === physical.value));
38
17
  return Option.isSome(hit) ? dirs.slice(0, dirs.indexOf(hit.value) + 1) : dirs;
39
18
  });
40
- const firstMatch = (candidates, predicate) => Effect.gen(function* () {
41
- for (const candidate of candidates) if (yield* Effect.catch(predicate(candidate), () => Effect.succeed(false))) return Option.some(candidate);
42
- return Option.none();
43
- });
44
- const findUpward = (dirs, candidatesFor) => Effect.gen(function* () {
45
- const fs = yield* FileSystem.FileSystem;
46
- const candidates = [];
47
- for (const dir of dirs) candidates.push(...candidatesFor(dir));
48
- return yield* firstMatch(candidates, (candidate) => fs.exists(candidate));
49
- });
50
- const findRoot = (dirs, isRoot) => firstMatch(dirs, isRoot);
51
19
  /**
52
20
  * Upward path traversal primitives: the directory chain from a start path to the
53
21
  * root, and first-match scans over it.
54
22
  *
55
23
  * @public
56
24
  */
57
- var Walker = class {
25
+ var Walker = class Walker {
58
26
  constructor() {}
59
27
  /**
60
28
  * Ascend from `start` toward the filesystem root, yielding each directory,
@@ -90,7 +58,25 @@ var Walker = class {
90
58
  * // Requires `Path` from the platform layer.
91
59
  * ```
92
60
  */
93
- static ascend = ascend;
61
+ static ascend(start, options) {
62
+ return Effect.gen(function* () {
63
+ const path = yield* Path.Path;
64
+ const maxDepth = options?.maxDepth ?? 256;
65
+ if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: maxDepth must be a positive integer, received ${maxDepth}`));
66
+ if (options?.stopAt !== void 0 && !path.isAbsolute(options.stopAt)) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: stopAt must be an absolute path, received ${JSON.stringify(options.stopAt)} (ascending from ${JSON.stringify(start)})`));
67
+ const ceiling = options?.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
68
+ const dirs = [];
69
+ let current = start;
70
+ for (let depth = 0; depth < maxDepth; depth++) {
71
+ dirs.push(current);
72
+ if (ceiling !== void 0 && (current === ceiling || path.resolve(current) === ceiling)) break;
73
+ const parent = path.dirname(current);
74
+ if (parent === current) break;
75
+ current = parent;
76
+ }
77
+ return dirs;
78
+ });
79
+ }
94
80
  /**
95
81
  * {@link Walker.ascend} bounded by a PHYSICAL ceiling: stops at the nearest
96
82
  * directory whose real path is the ceiling's real path, inclusive.
@@ -118,7 +104,12 @@ var Walker = class {
118
104
  * A present ceiling must be absolute; a relative one **dies**, for the
119
105
  * reasons {@link AscendOptions.stopAt} gives.
120
106
  */
121
- static ascendWithin = ascendWithin;
107
+ static ascendWithin(start, ceiling, options) {
108
+ return Effect.gen(function* () {
109
+ if (Option.isNone(ceiling)) return yield* Walker.ascend(start, options);
110
+ return yield* ascendToPhysical(start, ceiling.value, options);
111
+ });
112
+ }
122
113
  /**
123
114
  * The first candidate whose `predicate` reports true, or `Option.none()`.
124
115
  *
@@ -133,7 +124,12 @@ var Walker = class {
133
124
  * programmer error and surfaces as a defect. Do not change this to
134
125
  * `Effect.catchCause` — the distinction is load-bearing.
135
126
  */
136
- static firstMatch = firstMatch;
127
+ static firstMatch(candidates, predicate) {
128
+ return Effect.gen(function* () {
129
+ for (const candidate of candidates) if (yield* Effect.catch(predicate(candidate), () => Effect.succeed(false))) return Option.some(candidate);
130
+ return Option.none();
131
+ });
132
+ }
137
133
  /**
138
134
  * The first existing path among the candidates `candidatesFor` produces for each
139
135
  * directory in `dirs`, scanned in order. Nearer directories win.
@@ -142,7 +138,14 @@ var Walker = class {
142
138
  * Candidates materialize up front, bounded by `dirs.length × candidatesFor`'s
143
139
  * output — a few hundred strings under the default `maxDepth`.
144
140
  */
145
- static findUpward = findUpward;
141
+ static findUpward(dirs, candidatesFor) {
142
+ return Effect.gen(function* () {
143
+ const fs = yield* FileSystem.FileSystem;
144
+ const candidates = [];
145
+ for (const dir of dirs) candidates.push(...candidatesFor(dir));
146
+ return yield* Walker.firstMatch(candidates, (candidate) => fs.exists(candidate));
147
+ });
148
+ }
146
149
  /**
147
150
  * The first directory in `dirs` that `isRoot` accepts.
148
151
  *
@@ -151,7 +154,9 @@ var Walker = class {
151
154
  * identity. `isRoot` is a caller-supplied marker test (a `.git` entry, a
152
155
  * `pnpm-workspace.yaml`), and its failures are absorbed per directory.
153
156
  */
154
- static findRoot = findRoot;
157
+ static findRoot(dirs, isRoot) {
158
+ return Walker.firstMatch(dirs, isRoot);
159
+ }
155
160
  };
156
161
 
157
162
  export { Walker };
package/index.d.ts CHANGED
@@ -145,6 +145,23 @@ interface DescendOptions {
145
145
  readonly maxDepth?: number;
146
146
  /** Directory names never descended into. Defaults to `["node_modules", ".git"]`; a custom list replaces the default. */
147
147
  readonly prune?: ReadonlyArray<string>;
148
+ /**
149
+ * Marker FILE names that make a directory a boundary — a nested unit (another
150
+ * package, say: `["package.json"]`) whose contents belong to a separate walk.
151
+ *
152
+ * @remarks
153
+ * A directory strictly below `cwd` holding a FILE with one of these names
154
+ * contributes nothing: none of its files match and its subtree is never
155
+ * descended. `cwd` itself is never a boundary, and a DIRECTORY named like a
156
+ * marker does not count. The rule is about where a file sits, not how the
157
+ * walk reached it, so it applies to a walk that starts below `cwd` (the
158
+ * pattern's literal prefix) and to a literal pattern alike: a boundary
159
+ * between `cwd` and the walk base, or between `cwd` and a literal file,
160
+ * also answers zero matches. Each directory pays one stat per marker it
161
+ * actually lists. Defaults to none. Not to be confused with
162
+ * `Walker.ascend`'s `stopAt`, an inclusive upward ceiling.
163
+ */
164
+ readonly boundaries?: ReadonlyArray<string>;
148
165
  /**
149
166
  * What an unreadable directory mid-walk does. `"fail"` (the default) fails
150
167
  * typed — downward enumeration must not silently swallow a subtree, or the
@@ -158,7 +175,9 @@ interface DescendOptions {
158
175
  readonly onUnreadable?: "fail" | "skip";
159
176
  /**
160
177
  * Whether to descend into symlinked directories. Defaults to `false`: a
161
- * symlinked directory is never entered (cycle safety). Under `true` links
178
+ * symlinked directory is never entered (cycle safety) — the walk base, and
179
+ * every directory on the way to it, included: a base reached through a link
180
+ * is an empty result. Under `true` links
162
181
  * are followed with the cycle guard kept underneath, in `@actions/glob`'s
163
182
  * `traversalChain` semantics: each descended directory records its real
164
183
  * path on its own branch's ancestor chain (only the base and each link pay
@@ -208,7 +227,7 @@ interface DescendRecordOptions extends Omit<DescendOptions, "onUnreadable"> {
208
227
  *
209
228
  * @public
210
229
  */
211
- interface UnreadableDirectory {
230
+ interface UnreadableDirectory<C = PlatformError.PlatformError> {
212
231
  /**
213
232
  * The directory's path relative to `cwd`, POSIX separators.
214
233
  *
@@ -220,7 +239,7 @@ interface UnreadableDirectory {
220
239
  */
221
240
  readonly path: string;
222
241
  /** The `readDirectory` (or, for a link under `followSymlinks`, `realPath`) failure, never `NotFound` (a vanished directory is a benign race and is not recorded). */
223
- readonly cause: PlatformError.PlatformError;
242
+ readonly cause: C;
224
243
  }
225
244
  /**
226
245
  * `descend`'s success value under `onUnreadable: "record"`: the matched
@@ -232,11 +251,11 @@ interface UnreadableDirectory {
232
251
  *
233
252
  * @public
234
253
  */
235
- interface DescendResult {
254
+ interface DescendResult<C = PlatformError.PlatformError> {
236
255
  /** Matching FILE paths relative to `cwd`, POSIX separators, sorted — identical in shape to the `"fail"`/`"skip"` success value. */
237
256
  readonly matches: ReadonlyArray<string>;
238
257
  /** Directories that could not be read, each with its cause, in walk order. */
239
- readonly unreadable: ReadonlyArray<UnreadableDirectory>;
258
+ readonly unreadable: ReadonlyArray<UnreadableDirectory<C>>;
240
259
  }
241
260
  declare const DescendError_base: Schema.Class<DescendError, Schema.TaggedStruct<"DescendError", {
242
261
  /** The glob pattern's source text. */
@@ -259,6 +278,35 @@ declare const DescendError_base: Schema.Class<DescendError, Schema.TaggedStruct<
259
278
  export declare class DescendError extends DescendError_base {
260
279
  get message(): string;
261
280
  }
281
+ /**
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
284
+ * classify it, and this keeps the answer instead of discarding it.
285
+ *
286
+ * The type parameter is the stat's type, so the sync walk reuses this shape
287
+ * with its port's stats.
288
+ *
289
+ * @public
290
+ */
291
+ interface DescendEntry<I = FileSystem.File.Info> {
292
+ /** The file's path relative to `cwd`, POSIX separators. */
293
+ readonly path: string;
294
+ /** `FileSystem.stat` of the file — links followed, so a symlinked file reports its target. */
295
+ readonly info: I;
296
+ }
297
+ /**
298
+ * `descendEntries`' success value under `onUnreadable: "record"`: the matched
299
+ * {@link DescendEntry} values plus every {@link UnreadableDirectory}, exactly
300
+ * as {@link DescendResult} carries them for `descend`.
301
+ *
302
+ * @public
303
+ */
304
+ interface DescendEntriesResult<I = FileSystem.File.Info, C = PlatformError.PlatformError> {
305
+ /** Matching files with their stats, sorted by path. */
306
+ readonly matches: ReadonlyArray<DescendEntry<I>>;
307
+ /** Directories that could not be read, each with its cause, in walk order. */
308
+ readonly unreadable: ReadonlyArray<UnreadableDirectory<C>>;
309
+ }
262
310
  /**
263
311
  * Expand a compiled glob pattern against the filesystem under
264
312
  * `onUnreadable: "record"`, resolving to a {@link DescendResult} — the
@@ -277,11 +325,16 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
277
325
  * FILE paths relative to `cwd` (POSIX separators), sorted by relative path.
278
326
  *
279
327
  * @remarks
280
- * A literal pattern (no magic, not negated) fast-paths to a single stat: the
328
+ * A path is answered in the spelling the pattern matched it under: `./src/*.ts`
329
+ * answers `./src/a.ts`, and `src/*.ts` answers `src/a.ts`. A file several
330
+ * brace alternatives match under different spellings (`{src,./src}/*.ts`) is
331
+ * answered once, in the spelling of the earliest of them in pattern order. A
332
+ * literal pattern (no magic, not negated) fast-paths to a single stat: the
281
333
  * result is `[source]` when it resolves to a file, `[]` otherwise — a missing
282
- * path is zero matches, not an error. A magic pattern walks from its literal
283
- * directory prefix (`GlobPattern.enumerationPrefix`); a NEGATED pattern can
284
- * match paths outside that prefix, so it walks from `cwd` itself. A missing
334
+ * path is zero matches, not an error. A magic pattern walks each of its
335
+ * `GlobPattern.enumerationRoots`, reading each directory once however many
336
+ * alternatives spell it; a NEGATED pattern can match paths outside those
337
+ * roots, so it walks from `cwd` itself. A missing
285
338
  * base directory is likewise an empty result, because zero matches is a
286
339
  * normal glob answer — as is any pattern that lexically climbs above `cwd`
287
340
  * via `..` segments (walked paths never contain `..`). "Never reads outside
@@ -294,7 +347,7 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
294
347
  *
295
348
  * Only files match. A symlink counts when it stat-resolves to a file
296
349
  * (`FileSystem.stat` follows links, as node's does); a symlinked directory is
297
- * never descended into by default (cycle safety — detected by a `readLink`
350
+ * never descended into by default (cycle safety — detected by an `lstat`
298
351
  * probe), unless `followSymlinks: true` asks for it, which follows links under
299
352
  * `@actions/glob`'s `traversalChain` cycle guard: a directory is a cycle only
300
353
  * when its real path is already an ancestor of the current branch, so sibling
@@ -332,6 +385,165 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
332
385
  * @public
333
386
  */
334
387
  export declare function descend(pattern: GlobPattern, options: DescendOptions): Effect.Effect<ReadonlyArray<string>, DescendError, FileSystem.FileSystem | Path.Path>;
388
+ /**
389
+ * `descend` under `onUnreadable: "record"`, keeping each match's stat:
390
+ * resolves to a {@link DescendEntriesResult}.
391
+ *
392
+ * @public
393
+ */
394
+ export declare function descendEntries(pattern: GlobPattern, options: DescendRecordOptions): Effect.Effect<DescendEntriesResult, DescendError, FileSystem.FileSystem | Path.Path>;
395
+ /**
396
+ * `descend`, keeping the stat the walk already read for each matched
397
+ * file: resolves to {@link DescendEntry} values sorted by path.
398
+ *
399
+ * @remarks
400
+ * The same walk, the same matches in the same order, the same errors — only
401
+ * the element type differs, and at no extra syscall (the walk stats every
402
+ * entry to classify it). For a cache signature over a tree, map each entry's
403
+ * `path` and `info.mtime` (or `info.size`) into whatever key the cache needs;
404
+ * the kit does not choose the fingerprint.
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * import { GlobPattern, GlobPatternOptions } from "@effected/glob";
409
+ * import { descendEntries } from "@effected/walker";
410
+ * import { Effect, Option } from "effect";
411
+ *
412
+ * const signature = Effect.gen(function* () {
413
+ * const pattern = yield* GlobPattern.compile("**", GlobPatternOptions.make({ dot: true }));
414
+ * const entries = yield* descendEntries(pattern, { cwd: "/repo/src", boundaries: ["package.json"] });
415
+ * return entries.map((e) => `${e.path}:${Option.match(e.info.mtime, { onNone: () => "", onSome: (d) => d.getTime() })}`).join("|");
416
+ * });
417
+ * ```
418
+ *
419
+ * @public
420
+ */
421
+ export declare function descendEntries(pattern: GlobPattern, options: DescendOptions): Effect.Effect<ReadonlyArray<DescendEntry>, DescendError, FileSystem.FileSystem | Path.Path>;
422
+ /**
423
+ * The stats {@link WalkerSyncFileSystem} answers — a structural subset of
424
+ * `node:fs` `Stats`, so `statSync` / `lstatSync` satisfy it verbatim.
425
+ *
426
+ * @public
427
+ */
428
+ interface WalkerSyncStats {
429
+ /** Whether the entry is a regular file. */
430
+ isFile(): boolean;
431
+ /** Whether the entry is a directory. */
432
+ isDirectory(): boolean;
433
+ /** Whether the entry is a symbolic link (only ever true for `lstat`). */
434
+ isSymbolicLink(): boolean;
435
+ /** Modification time, epoch milliseconds. */
436
+ readonly mtimeMs: number;
437
+ /** Size in bytes. */
438
+ readonly size: number;
439
+ }
440
+ /**
441
+ * The synchronous file operations the sync walk needs, supplied by the
442
+ * consumer — `node:fs` satisfies it (`nodeWalkerFileSystem` from
443
+ * `@effected/walker/node-sync`).
444
+ *
445
+ * @remarks
446
+ * Every member reports failure by throwing a node-shaped error. A `code` of
447
+ * `"ENOENT"` is the benign vanished-path race (empty, never recorded); any
448
+ * other throw from `readDirectory` or `realPath` is an unreadable directory,
449
+ * handled per `onUnreadable`. A throwing `stat` or `lstat` reads as "no
450
+ * entry", exactly as the `Effect` walk reads its failed `stat` and `lstat`.
451
+ *
452
+ * @public
453
+ */
454
+ interface WalkerSyncFileSystem {
455
+ /** The entry names inside the directory at `path` (`readdirSync`). */
456
+ readonly readDirectory: (path: string) => ReadonlyArray<string>;
457
+ /** Stats of `path`, following links (`statSync`). */
458
+ readonly stat: (path: string) => WalkerSyncStats;
459
+ /** Stats of `path` itself, not following a final link (`lstatSync`). */
460
+ readonly lstat: (path: string) => WalkerSyncStats;
461
+ /** The real path of `path`, links resolved (`realpathSync`). */
462
+ readonly realPath: (path: string) => string;
463
+ }
464
+ /**
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)
467
+ * satisfies it. It shapes only the absolute paths handed to the
468
+ * {@link WalkerSyncFileSystem}; matching is POSIX-relative by the glob
469
+ * contract.
470
+ *
471
+ * @public
472
+ */
473
+ interface WalkerSyncPath {
474
+ /** Join segments with the implementation's separator (like `path.join`). */
475
+ readonly join: (...segments: ReadonlyArray<string>) => string;
476
+ }
477
+ /**
478
+ * The platform half of every sync walk.
479
+ *
480
+ * @public
481
+ */
482
+ interface WalkerSyncOps {
483
+ /** File operations. */
484
+ readonly fileSystem: WalkerSyncFileSystem;
485
+ /** Path joining. */
486
+ readonly path: WalkerSyncPath;
487
+ }
488
+ /**
489
+ * Options for `descendSync` / `descendEntriesSync`: every `DescendOptions`
490
+ * field, plus the platform.
491
+ *
492
+ * @public
493
+ */
494
+ interface DescendSyncOptions extends DescendOptions, WalkerSyncOps {}
495
+ /**
496
+ * {@link DescendSyncOptions} under `onUnreadable: "record"`.
497
+ *
498
+ * @public
499
+ */
500
+ interface DescendSyncRecordOptions extends DescendRecordOptions, WalkerSyncOps {}
501
+ /**
502
+ * `descend` under `onUnreadable: "record"`, synchronously over a caller-supplied port.
503
+ *
504
+ * @public
505
+ */
506
+ export declare function descendSync(pattern: GlobPattern, options: DescendSyncRecordOptions): Result.Result<DescendResult<unknown>, DescendError>;
507
+ /**
508
+ * `descend`, synchronously, over a caller-supplied file port: matching FILE
509
+ * paths relative to `cwd` (POSIX separators), sorted.
510
+ *
511
+ * @remarks
512
+ * The same walk as `descend` — it drives the same traversal, so every rule
513
+ * holds identically: the literal fast path, the enumeration prefix, `prune`,
514
+ * `boundaries`, the symlink posture and per-branch cycle guard under
515
+ * `followSymlinks`, `onUnreadable` and the `maxDepth` failure. Failures are a
516
+ * `Result` failure typed as `DescendError`; an invalid `maxDepth` is a defect
517
+ * and throws. For a caller with no Effect runtime (config evaluation, a Node
518
+ * SEA sidecar); inside Effect, use `descend`.
519
+ *
520
+ * @example
521
+ * ```ts
522
+ * import { GlobPattern } from "@effected/glob";
523
+ * import { descendSync } from "@effected/walker";
524
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
525
+ * import { Result } from "effect";
526
+ *
527
+ * const pattern = Result.getOrThrow(GlobPattern.compileResult("src/**"));
528
+ * const files = descendSync(pattern, { cwd: "/repo", boundaries: ["package.json"], ...nodeWalkerSyncOps });
529
+ * ```
530
+ *
531
+ * @public
532
+ */
533
+ export declare function descendSync(pattern: GlobPattern, options: DescendSyncOptions): Result.Result<ReadonlyArray<string>, DescendError>;
534
+ /**
535
+ * `descendEntries` under `onUnreadable: "record"`, synchronously over a caller-supplied port.
536
+ *
537
+ * @public
538
+ */
539
+ export declare function descendEntriesSync(pattern: GlobPattern, options: DescendSyncRecordOptions): Result.Result<DescendEntriesResult<WalkerSyncStats, unknown>, DescendError>;
540
+ /**
541
+ * `descendEntries`, synchronously: `descendSync`'s matches, each with
542
+ * the stat the walk already read for it.
543
+ *
544
+ * @public
545
+ */
546
+ export declare function descendEntriesSync(pattern: GlobPattern, options: DescendSyncOptions): Result.Result<ReadonlyArray<DescendEntry<WalkerSyncStats>>, DescendError>;
335
547
  /**
336
548
  * Options for {@link compileAndExpand}: every {@link DescendOptions} field,
337
549
  * plus the glob options the pattern compiles under.
@@ -530,7 +742,7 @@ export declare class Walker {
530
742
  * // Requires `Path` from the platform layer.
531
743
  * ```
532
744
  */
533
- static readonly ascend: (start: string, options?: AscendOptions) => Effect.Effect<ReadonlyArray<string>, never, Path.Path>;
745
+ static ascend(start: string, options?: AscendOptions): Effect.Effect<ReadonlyArray<string>, never, Path.Path>;
534
746
  /**
535
747
  * {@link Walker.ascend} bounded by a PHYSICAL ceiling: stops at the nearest
536
748
  * directory whose real path is the ceiling's real path, inclusive.
@@ -558,7 +770,7 @@ export declare class Walker {
558
770
  * A present ceiling must be absolute; a relative one **dies**, for the
559
771
  * reasons {@link AscendOptions.stopAt} gives.
560
772
  */
561
- static readonly ascendWithin: (start: string, ceiling: Option.Option<string>, options?: Pick<AscendOptions, "maxDepth">) => Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path>;
773
+ static ascendWithin(start: string, ceiling: Option.Option<string>, options?: Pick<AscendOptions, "maxDepth">): Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path>;
562
774
  /**
563
775
  * The first candidate whose `predicate` reports true, or `Option.none()`.
564
776
  *
@@ -573,7 +785,7 @@ export declare class Walker {
573
785
  * programmer error and surfaces as a defect. Do not change this to
574
786
  * `Effect.catchCause` — the distinction is load-bearing.
575
787
  */
576
- static readonly firstMatch: <E, R>(candidates: ReadonlyArray<string>, predicate: (candidate: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
788
+ static firstMatch<E, R>(candidates: ReadonlyArray<string>, predicate: (candidate: string) => Effect.Effect<boolean, E, R>): Effect.Effect<Option.Option<string>, never, R>;
577
789
  /**
578
790
  * The first existing path among the candidates `candidatesFor` produces for each
579
791
  * directory in `dirs`, scanned in order. Nearer directories win.
@@ -582,7 +794,7 @@ export declare class Walker {
582
794
  * Candidates materialize up front, bounded by `dirs.length × candidatesFor`'s
583
795
  * output — a few hundred strings under the default `maxDepth`.
584
796
  */
585
- static readonly findUpward: (dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>) => Effect.Effect<Option.Option<string>, never, FileSystem.FileSystem>;
797
+ static findUpward(dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>): Effect.Effect<Option.Option<string>, never, FileSystem.FileSystem>;
586
798
  /**
587
799
  * The first directory in `dirs` that `isRoot` accepts.
588
800
  *
@@ -591,7 +803,7 @@ export declare class Walker {
591
803
  * identity. `isRoot` is a caller-supplied marker test (a `.git` entry, a
592
804
  * `pnpm-workspace.yaml`), and its failures are absorbed per directory.
593
805
  */
594
- static readonly findRoot: <E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
806
+ static findRoot<E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>): Effect.Effect<Option.Option<string>, never, R>;
595
807
  }
596
- export type { AscendOptions, CompileAndExpandOptions, DescendOptions, DescendRecordOptions, DescendResult, UnreadableDirectory };
808
+ export type { AscendOptions, CompileAndExpandOptions, DescendEntriesResult, DescendEntry, DescendOptions, DescendRecordOptions, DescendResult, DescendSyncOptions, DescendSyncRecordOptions, UnreadableDirectory, WalkerSyncFileSystem, WalkerSyncOps, WalkerSyncPath, WalkerSyncStats };
597
809
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { PathEscapeError, PathEscapeReason, confineRelative, confineRelativeResult } from "./Confine.js";
2
- import { DescendError, descend } from "./Descend.js";
2
+ import { DescendError, descend, descendEntries } from "./Descend.js";
3
+ import { descendEntriesSync, descendSync } from "./DescendSync.js";
3
4
  import { GlobExpansionError, compileAndExpand } from "./Expand.js";
4
5
  import { Walker } from "./Walker.js";
5
6
 
6
- export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineRelative, confineRelativeResult, descend };
7
+ export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineRelative, confineRelativeResult, descend, descendEntries, descendEntriesSync, descendSync };
@@ -0,0 +1,240 @@
1
+ import { resolveSegments } from "./segments.js";
2
+ import { trimTrailing } from "./trimTrailing.js";
3
+
4
+ // src/internal/descendMachine.ts
5
+ /** Directory names never descended into unless the caller overrides `prune`. */
6
+ const DEFAULT_PRUNE = ["node_modules", ".git"];
7
+ /** A glob pattern's one separator: its backslash is an escape, never a separator. */
8
+ const isSlash = (character) => character === "/";
9
+ /** Shared empty ancestor chain: frames under `followSymlinks: false` never consult theirs. */
10
+ const NO_ANCESTORS = Object.freeze([]);
11
+ /** The walk. */
12
+ function* descendMachine(pattern, options, join) {
13
+ /** Yields one request; the answer is typed by the request's tag. */
14
+ function* ask(request) {
15
+ return yield request;
16
+ }
17
+ const maxDepth = options.maxDepth ?? 256;
18
+ if (!Number.isInteger(maxDepth) || maxDepth < 1) return {
19
+ _tag: "Defect",
20
+ message: `Walker.descend: maxDepth must be a positive integer, received ${maxDepth}`
21
+ };
22
+ const prune = new Set(options.prune ?? DEFAULT_PRUNE);
23
+ const boundaries = options.boundaries ?? [];
24
+ const onUnreadable = options.onUnreadable ?? "fail";
25
+ const followSymlinks = options.followSymlinks ?? false;
26
+ const unreadable = [];
27
+ const done = (entries) => {
28
+ entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
29
+ return {
30
+ _tag: "Done",
31
+ entries,
32
+ unreadable
33
+ };
34
+ };
35
+ const unreadableFail = (path) => ({
36
+ _tag: "Fail",
37
+ reason: "unreadableDirectory",
38
+ path
39
+ });
40
+ /**
41
+ * Whether the directory at `absolute` is a boundary: it holds a marker
42
+ * FILE. `listing`, when the directory was just read, spares the stat for
43
+ * every marker it does not name; a directory named like a marker is not one.
44
+ */
45
+ function* isBoundary(absolute, listing) {
46
+ for (const marker of boundaries) {
47
+ if (listing !== void 0 && !listing.includes(marker)) continue;
48
+ if ((yield* ask({
49
+ _tag: "Stat",
50
+ path: join(absolute, marker)
51
+ }))?.kind === "File") return true;
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * Whether the way down `segments` (below `cwd`) is closed: a directory among
57
+ * the first `boundaryCount` is a boundary, or — without followSymlinks,
58
+ * where a symlinked directory is never entered — a link is among the first
59
+ * `directoryCount`. Either way the answer is nothing.
60
+ */
61
+ function* blocked(segments, directoryCount, boundaryCount) {
62
+ if (boundaries.length === 0 && followSymlinks) return false;
63
+ let absolute = options.cwd;
64
+ for (let index = 0; index < directoryCount; index += 1) {
65
+ absolute = join(absolute, segments[index] ?? "");
66
+ if (index < boundaryCount && (yield* isBoundary(absolute, void 0))) return true;
67
+ if (!followSymlinks && (yield* ask({
68
+ _tag: "Lstat",
69
+ path: absolute
70
+ }))?.kind === "SymbolicLink") return true;
71
+ }
72
+ return false;
73
+ }
74
+ /** The real path of a directory the cycle guard must identify, or `undefined` when the walk must not enter it. */
75
+ function* realPathOf(absolute, relative) {
76
+ const answer = yield* ask({
77
+ _tag: "RealPath",
78
+ path: absolute
79
+ });
80
+ if (answer._tag === "Ok") return answer.value;
81
+ if (answer._tag === "NotFound") return void 0;
82
+ if (onUnreadable === "fail") return { fail: true };
83
+ if (onUnreadable === "record") unreadable.push({
84
+ path: relative,
85
+ cause: answer.cause
86
+ });
87
+ }
88
+ if (!pattern.hasMagic && !pattern.negated && !/[{}]/.test(pattern.source)) {
89
+ const segments = resolveSegments(pattern.source.split("/"));
90
+ if (segments === void 0) return done([]);
91
+ if (yield* blocked(segments, segments.length - 1, segments.length - 1)) return done([]);
92
+ const answer = yield* ask({
93
+ _tag: "Stat",
94
+ path: join(options.cwd, pattern.source)
95
+ });
96
+ return done(answer?.kind === "File" ? [{
97
+ path: pattern.source,
98
+ info: answer.info
99
+ }] : []);
100
+ }
101
+ const results = [];
102
+ /**
103
+ * Walks one root: the literal directory `base` and everything below it a
104
+ * match can lie in — one level, or all of it when `deep`. An absent base,
105
+ * one that climbs above `cwd`, or one behind a boundary or a link is
106
+ * skipped; a `Fail` aborts the walk.
107
+ *
108
+ * The walk itself is canonical (`cwd` is `""`, never `.`), so each directory
109
+ * is read once however many spellings name it. What it answers is spelled
110
+ * the way the pattern spells it: a file matches when its path, re-spelled
111
+ * through one of the root's spellings, matches the pattern, and it is
112
+ * answered ONCE, in the first such spelling — the earliest brace
113
+ * alternative's, since spellings come in pattern order. A directory the
114
+ * walk reports (unreadable, too deep) is spelled through the root's own
115
+ * earliest spelling.
116
+ */
117
+ function* walkRoot(rawBase, deep, spellings) {
118
+ const spelled = spellings.map((spelling) => {
119
+ const canonical = resolveSegments(spelling.split("/"))?.join("/") ?? "";
120
+ return {
121
+ spelling,
122
+ canonical: canonical === "" ? "" : `${canonical}/`
123
+ };
124
+ });
125
+ /** The file at canonical `relative`, in the first spelling the pattern matches it under, or `undefined`. */
126
+ const matchedAs = (relative) => {
127
+ for (const { spelling, canonical } of spelled) {
128
+ if (canonical !== "" && !relative.startsWith(canonical)) continue;
129
+ const candidate = spelling + relative.slice(canonical.length);
130
+ if (pattern.matches(candidate)) return candidate;
131
+ }
132
+ };
133
+ const baseSegments = resolveSegments(rawBase.split("/"));
134
+ if (baseSegments === void 0) return void 0;
135
+ const base = baseSegments.join("/");
136
+ const baseCanonical = base === "" ? "" : `${base}/`;
137
+ const own = spelled.find(({ canonical }) => canonical === baseCanonical)?.spelling ?? baseCanonical;
138
+ const shown = (relative) => relative === base ? trimTrailing(own, isSlash) : own + relative.slice(baseCanonical.length);
139
+ const absoluteBase = base === "" ? options.cwd : join(options.cwd, base);
140
+ if ((yield* ask({
141
+ _tag: "Stat",
142
+ path: absoluteBase
143
+ }))?.kind !== "Directory") return void 0;
144
+ if (yield* blocked(baseSegments, baseSegments.length, baseSegments.length - 1)) return void 0;
145
+ let baseAncestors = NO_ANCESTORS;
146
+ if (followSymlinks) {
147
+ const real = yield* realPathOf(absoluteBase, shown(base));
148
+ if (typeof real === "object") return unreadableFail(shown(base));
149
+ if (real === void 0) return void 0;
150
+ baseAncestors = [real];
151
+ }
152
+ const frames = [{
153
+ relative: base,
154
+ absolute: absoluteBase,
155
+ depth: 0,
156
+ ancestors: baseAncestors
157
+ }];
158
+ for (let head = 0; head < frames.length; head += 1) {
159
+ const frame = frames[head];
160
+ if (frame === void 0) break;
161
+ const read = yield* ask({
162
+ _tag: "ReadDirectory",
163
+ path: frame.absolute
164
+ });
165
+ let entries = [];
166
+ if (read._tag === "Ok") entries = read.value;
167
+ else if (read._tag === "Failed") {
168
+ if (onUnreadable === "fail") return unreadableFail(shown(frame.relative));
169
+ if (onUnreadable === "record") unreadable.push({
170
+ path: shown(frame.relative),
171
+ cause: read.cause
172
+ });
173
+ }
174
+ if (frame.relative !== "" && (yield* isBoundary(frame.absolute, entries))) continue;
175
+ for (const entry of entries) {
176
+ const relative = frame.relative === "" ? entry : `${frame.relative}/${entry}`;
177
+ const absolute = join(frame.absolute, entry);
178
+ const own = yield* ask({
179
+ _tag: "Lstat",
180
+ path: absolute
181
+ });
182
+ const link = own?.kind === "SymbolicLink";
183
+ const stat = link ? yield* ask({
184
+ _tag: "Stat",
185
+ path: absolute
186
+ }) : own;
187
+ if (stat === void 0) continue;
188
+ if (stat.kind === "File") {
189
+ const path = matchedAs(relative);
190
+ if (path !== void 0) results.push({
191
+ path,
192
+ info: stat.info
193
+ });
194
+ continue;
195
+ }
196
+ if (stat.kind !== "Directory" || !deep) continue;
197
+ if (prune.has(entry)) continue;
198
+ let ancestors = frame.ancestors;
199
+ if (followSymlinks) {
200
+ const parentReal = frame.ancestors[frame.ancestors.length - 1] ?? frame.absolute;
201
+ const real = link ? yield* realPathOf(absolute, shown(relative)) : join(parentReal, entry);
202
+ if (typeof real === "object") return unreadableFail(shown(relative));
203
+ if (real === void 0 || frame.ancestors.includes(real)) continue;
204
+ ancestors = [...frame.ancestors, real];
205
+ } else if (link) continue;
206
+ if (frame.depth + 1 > maxDepth) return {
207
+ _tag: "Fail",
208
+ reason: "depthExceeded",
209
+ path: shown(frame.relative),
210
+ limit: maxDepth
211
+ };
212
+ frames.push({
213
+ relative,
214
+ absolute,
215
+ depth: frame.depth + 1,
216
+ ancestors
217
+ });
218
+ }
219
+ }
220
+ }
221
+ const roots = pattern.negated ? [{
222
+ prefix: "",
223
+ deep: true,
224
+ spellings: [""]
225
+ }] : pattern.enumerationRoots;
226
+ for (const root of roots) {
227
+ const failed = yield* walkRoot(trimTrailing(root.prefix, isSlash), root.deep, root.spellings);
228
+ if (failed !== void 0) return failed;
229
+ }
230
+ return done(results);
231
+ }
232
+ /** The typed error a `Fail` result describes — the one conversion both drivers share. */
233
+ const failureOf = (pattern, result) => ({
234
+ pattern: pattern.source,
235
+ reason: result.reason,
236
+ path: result.path,
237
+ ...result.limit === void 0 ? {} : { limit: result.limit }
238
+ });
239
+
240
+ export { DEFAULT_PRUNE, descendMachine, failureOf };
@@ -0,0 +1,13 @@
1
+ // src/internal/trimTrailing.ts
2
+ /**
3
+ * `text` without its trailing run of characters `isTrailing` accepts, in time linear in the run.
4
+ *
5
+ * @internal
6
+ */
7
+ const trimTrailing = (text, isTrailing) => {
8
+ let end = text.length;
9
+ while (end > 0 && isTrailing(text.charAt(end - 1))) end -= 1;
10
+ return end === text.length ? text : text.slice(0, end);
11
+ };
12
+
13
+ export { trimTrailing };
package/node-sync.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ import "@effected/glob";
2
+ import "effect";
3
+ /**
4
+ * The stats {@link WalkerSyncFileSystem} answers — a structural subset of
5
+ * `node:fs` `Stats`, so `statSync` / `lstatSync` satisfy it verbatim.
6
+ *
7
+ * @public
8
+ */
9
+ interface WalkerSyncStats {
10
+ /** Whether the entry is a regular file. */
11
+ isFile(): boolean;
12
+ /** Whether the entry is a directory. */
13
+ isDirectory(): boolean;
14
+ /** Whether the entry is a symbolic link (only ever true for `lstat`). */
15
+ isSymbolicLink(): boolean;
16
+ /** Modification time, epoch milliseconds. */
17
+ readonly mtimeMs: number;
18
+ /** Size in bytes. */
19
+ readonly size: number;
20
+ }
21
+ /**
22
+ * The synchronous file operations the sync walk needs, supplied by the
23
+ * consumer — `node:fs` satisfies it (`nodeWalkerFileSystem` from
24
+ * `@effected/walker/node-sync`).
25
+ *
26
+ * @remarks
27
+ * Every member reports failure by throwing a node-shaped error. A `code` of
28
+ * `"ENOENT"` is the benign vanished-path race (empty, never recorded); any
29
+ * other throw from `readDirectory` or `realPath` is an unreadable directory,
30
+ * handled per `onUnreadable`. A throwing `stat` or `lstat` reads as "no
31
+ * entry", exactly as the `Effect` walk reads its failed `stat` and `lstat`.
32
+ *
33
+ * @public
34
+ */
35
+ interface WalkerSyncFileSystem {
36
+ /** The entry names inside the directory at `path` (`readdirSync`). */
37
+ readonly readDirectory: (path: string) => ReadonlyArray<string>;
38
+ /** Stats of `path`, following links (`statSync`). */
39
+ readonly stat: (path: string) => WalkerSyncStats;
40
+ /** Stats of `path` itself, not following a final link (`lstatSync`). */
41
+ readonly lstat: (path: string) => WalkerSyncStats;
42
+ /** The real path of `path`, links resolved (`realpathSync`). */
43
+ readonly realPath: (path: string) => string;
44
+ }
45
+ /**
46
+ * The synchronous path operation the sync walk needs — a structural subset of
47
+ * `node:path`, so the module itself (or its `win32` / `posix` variant)
48
+ * satisfies it. It shapes only the absolute paths handed to the
49
+ * {@link WalkerSyncFileSystem}; matching is POSIX-relative by the glob
50
+ * contract.
51
+ *
52
+ * @public
53
+ */
54
+ interface WalkerSyncPath {
55
+ /** Join segments with the implementation's separator (like `path.join`). */
56
+ readonly join: (...segments: ReadonlyArray<string>) => string;
57
+ }
58
+ /**
59
+ * The platform half of every sync walk.
60
+ *
61
+ * @public
62
+ */
63
+ interface WalkerSyncOps {
64
+ /** File operations. */
65
+ readonly fileSystem: WalkerSyncFileSystem;
66
+ /** Path joining. */
67
+ readonly path: WalkerSyncPath;
68
+ }
69
+ /**
70
+ * `WalkerSyncFileSystem` over `node:fs`: `readdirSync`, `statSync`,
71
+ * `lstatSync`, `realpathSync`.
72
+ *
73
+ * @public
74
+ */
75
+ export declare const nodeWalkerFileSystem: WalkerSyncFileSystem;
76
+ /**
77
+ * `WalkerSyncPath` over `node:path` (the host platform's separator).
78
+ *
79
+ * @public
80
+ */
81
+ export declare const nodeWalkerPath: WalkerSyncPath;
82
+ /**
83
+ * Both bindings, to spread into `descendSync` options.
84
+ *
85
+ * @public
86
+ */
87
+ export declare const nodeWalkerSyncOps: WalkerSyncOps;
88
+ export type { WalkerSyncFileSystem, WalkerSyncOps, WalkerSyncPath, WalkerSyncStats };
89
+ //# sourceMappingURL=node-sync.d.ts.map
package/node-sync.js ADDED
@@ -0,0 +1,55 @@
1
+ import { lstatSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ // src/node-sync.ts
5
+ /**
6
+ * The Node.js binding for the synchronous walk — a ready-made
7
+ * `WalkerSyncFileSystem` over `node:fs` and `WalkerSyncPath` over `node:path`,
8
+ * so adopting `descendSync` is one import.
9
+ *
10
+ * Deliberately a **separate subpath** (`@effected/walker/node-sync`), not part
11
+ * of the main entry: the main entry takes its platform through `R` (or, for the
12
+ * sync forms, from the caller) and imports nothing platform-shaped.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { GlobPattern } from "@effected/glob";
17
+ * import { descendSync } from "@effected/walker";
18
+ * import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
19
+ * import { Result } from "effect";
20
+ *
21
+ * const pattern = Result.getOrThrow(GlobPattern.compileResult("src/**"));
22
+ * const files = descendSync(pattern, { cwd: process.cwd(), ...nodeWalkerSyncOps });
23
+ * ```
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+ /**
28
+ * `WalkerSyncFileSystem` over `node:fs`: `readdirSync`, `statSync`,
29
+ * `lstatSync`, `realpathSync`.
30
+ *
31
+ * @public
32
+ */
33
+ const nodeWalkerFileSystem = {
34
+ readDirectory: (p) => readdirSync(p),
35
+ stat: (p) => statSync(p),
36
+ lstat: (p) => lstatSync(p),
37
+ realPath: (p) => realpathSync(p)
38
+ };
39
+ /**
40
+ * `WalkerSyncPath` over `node:path` (the host platform's separator).
41
+ *
42
+ * @public
43
+ */
44
+ const nodeWalkerPath = { join: path.join };
45
+ /**
46
+ * Both bindings, to spread into `descendSync` options.
47
+ *
48
+ * @public
49
+ */
50
+ const nodeWalkerSyncOps = {
51
+ fileSystem: nodeWalkerFileSystem,
52
+ path: nodeWalkerPath
53
+ };
54
+
55
+ export { nodeWalkerFileSystem, nodeWalkerPath, nodeWalkerSyncOps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/walker",
3
- "version": "0.20.0",
3
+ "version": "0.21.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": [
@@ -34,10 +34,15 @@
34
34
  "import": "./index.js",
35
35
  "default": "./index.js"
36
36
  },
37
+ "./node-sync": {
38
+ "types": "./node-sync.d.ts",
39
+ "import": "./node-sync.js",
40
+ "default": "./node-sync.js"
41
+ },
37
42
  "./package.json": "./package.json"
38
43
  },
39
44
  "peerDependencies": {
40
- "@effected/glob": "^0.14.0",
45
+ "@effected/glob": "^0.15.0",
41
46
  "effect": "^4.0.0"
42
47
  },
43
48
  "engines": {
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.1"
8
+ "packageVersion": "7.59.4"
9
9
  }
10
10
  ]
11
11
  }