@effected/walker 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Expand.js ADDED
@@ -0,0 +1,96 @@
1
+ import { DescendError, descend } from "./Descend.js";
2
+ import { Effect, Result, Schema } from "effect";
3
+ import { GlobPattern, GlobPatternError } from "@effected/glob";
4
+
5
+ //#region src/Expand.ts
6
+ /**
7
+ * Typed failure raised by {@link compileAndExpand}: the single error the
8
+ * compile+expand recipe fails with, so a caller catches one tag rather than
9
+ * folding two error channels by hand.
10
+ *
11
+ * @remarks
12
+ * One tag, two genuinely different causes — "your pattern is malformed" and
13
+ * "that directory is unreadable" are different problems with different fixes,
14
+ * so `cause` keeps the underlying typed error intact
15
+ * rather than flattening it into a string. Discriminate on `cause._tag`
16
+ * (`"GlobPatternError"` vs `"DescendError"`), or read
17
+ * {@link GlobExpansionError.stage} when only the phase matters; either way the
18
+ * original payload — a guard's `limit`/`actual`, a descent's `path` — is still
19
+ * there. `cause` is also the native `Error` cause, so error chaining and
20
+ * stack-printing work without extra wiring.
21
+ *
22
+ * @public
23
+ */
24
+ var GlobExpansionError = class extends Schema.TaggedErrorClass()("GlobExpansionError", {
25
+ /** The glob pattern's source text, as handed to {@link compileAndExpand}. */
26
+ pattern: Schema.String,
27
+ /** The underlying typed failure, intact: a compile guard trip or a descent failure. */
28
+ cause: Schema.Union([GlobPatternError, DescendError])
29
+ }) {
30
+ /**
31
+ * Which phase failed — `"compile"` when the pattern itself was rejected,
32
+ * `"descend"` when the filesystem walk failed. A convenience over
33
+ * `cause._tag` for callers that only need the phase.
34
+ */
35
+ get stage() {
36
+ return this.cause._tag === "GlobPatternError" ? "compile" : "descend";
37
+ }
38
+ get message() {
39
+ const shown = this.pattern.length > 64 ? `${this.pattern.slice(0, 64)}…` : this.pattern;
40
+ return `glob expansion of ${JSON.stringify(shown)} failed during ${this.stage}: ${this.cause.message}`;
41
+ }
42
+ };
43
+ /**
44
+ * Compile a glob pattern and expand it against the filesystem in one call:
45
+ * matching FILE paths relative to `options.cwd`, POSIX separators, sorted.
46
+ *
47
+ * @remarks
48
+ * The recipe form of {@link descend}. Everything `descend` documents about
49
+ * traversal holds unchanged — the literal fast-path, the negated-pattern walk
50
+ * from `cwd`, files-only matching, symlink and prune handling, `maxDepth`,
51
+ * `onUnreadable` — because this delegates to it. What this adds is the seam:
52
+ * the pattern arrives as a string, and both failure modes arrive as one
53
+ * {@link GlobExpansionError} with the underlying error preserved in `cause`.
54
+ *
55
+ * A missing base directory, a pattern that climbs above `cwd`, and a pattern
56
+ * that simply matches nothing are all an EMPTY result, not a failure — zero
57
+ * matches is a normal glob answer. Only a rejected pattern or a failed walk
58
+ * produces an error.
59
+ *
60
+ * `FileSystem` and `Path` stay in the `R` channel and are **deliberately not
61
+ * provided here**, even though hand-providing them is the friction this
62
+ * recipe otherwise removes. `FileSystem` cannot be provided — a library that
63
+ * picks its own filesystem cannot be tested against a fixture tree. Given
64
+ * that, providing `Path` internally would not save the caller a layer (they
65
+ * still supply `FileSystem`), and it would actively break win32: the walk
66
+ * would join paths POSIX-style against a caller's win32 filesystem. The
67
+ * consumer's platform layer stays the single place that choice is made.
68
+ * Provide both once at the application boundary, not per call site.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { compileAndExpand } from "@effected/walker"
73
+ * import { GlobPatternOptions } from "@effected/glob"
74
+ *
75
+ * const files = yield* compileAndExpand("packages/*​/src/**​/*.ts", {
76
+ * cwd: "/repo",
77
+ * glob: GlobPatternOptions.make({ dot: true })
78
+ * })
79
+ * ```
80
+ *
81
+ * @public
82
+ */
83
+ const compileAndExpand = Effect.fn("Walker.compileAndExpand")(function* (pattern, options) {
84
+ const compiled = GlobPattern.compileResult(pattern, options.glob);
85
+ if (Result.isFailure(compiled)) return yield* new GlobExpansionError({
86
+ pattern,
87
+ cause: compiled.failure
88
+ });
89
+ return yield* descend(compiled.success, options).pipe(Effect.mapError((cause) => new GlobExpansionError({
90
+ pattern,
91
+ cause
92
+ })));
93
+ });
94
+
95
+ //#endregion
96
+ export { GlobExpansionError, compileAndExpand };
package/README.md CHANGED
@@ -82,7 +82,7 @@ The predicate can be expensive — reading and parsing a `package.json` to decid
82
82
 
83
83
  ## Features
84
84
 
85
- - `Walker.ascend(start, options?)` — the directory chain from `start` toward the filesystem root, nearest first. `stopAt` halts the ascent inclusively, `maxDepth` (default 256) caps it. Lexical, not physical: `Path.dirname` does not resolve symlinks, so ascending out of a symlinked directory follows the path you were given.
85
+ - `Walker.ascend(start, options?)` — the directory chain from `start` toward the filesystem root, nearest first. `stopAt` halts the ascent inclusively — it must be absolute, and is matched in normalized form, so a trailing separator or a `.`/`..` segment still stops where it names; a relative ceiling is a defect rather than being resolved against the working directory, exactly as an invalid `maxDepth` is. `maxDepth` (default 256) caps the chain. Lexical, not physical: `Path.dirname` does not resolve symlinks, so ascending out of a symlinked directory follows the path you were given.
86
86
  - `Walker.firstMatch(candidates, predicate)` — the first candidate the predicate accepts. Absorbs each predicate failure individually and short-circuits at the first match.
87
87
  - `Walker.findUpward(dirs, candidatesFor)` — the first existing path, directory-major: every candidate in the nearest directory is tried before ascending.
88
88
  - `Walker.findRoot(dirs, isRoot)` — the nearest directory a marker predicate accepts. `firstMatch` where the candidate expansion is the identity.
package/Walker.js CHANGED
@@ -14,17 +14,28 @@ import { Effect, FileSystem, Option, Path } from "effect";
14
14
  * Bounded twice over: `dirname` is a fixpoint at the root, and `maxDepth` guards
15
15
  * a pathological `Path` implementation that never reaches one.
16
16
  *
17
+ * `stopAt` is compared in normalized form — see {@link AscendOptions.stopAt}.
18
+ * Raw string equality made the ceiling fail OPEN: an unnormalized ceiling
19
+ * matched nothing and the ascent ran silently to the filesystem root, which is
20
+ * the unbounded walk the option exists to prevent, with no error to notice it
21
+ * by. A relative ceiling **dies** for the same reason — resolving one against
22
+ * `process.cwd()` would reintroduce the silent-wrong-walk failure through a
23
+ * different door. See {@link AscendOptions.stopAt} for why that is a defect
24
+ * rather than a typed error; the error channel stays `never`.
25
+ *
17
26
  * @public
18
27
  */
19
28
  const ascend = (start, options) => Effect.gen(function* () {
20
29
  const path = yield* Path.Path;
21
30
  const maxDepth = options?.maxDepth ?? 256;
22
31
  if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: maxDepth must be a positive integer, received ${maxDepth}`));
32
+ 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)})`));
33
+ const ceiling = options?.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
23
34
  const dirs = [];
24
35
  let current = start;
25
36
  for (let depth = 0; depth < maxDepth; depth++) {
26
37
  dirs.push(current);
27
- if (options?.stopAt !== void 0 && current === options.stopAt) break;
38
+ if (ceiling !== void 0 && (current === ceiling || path.resolve(current) === ceiling)) break;
28
39
  const parent = path.dirname(current);
29
40
  if (parent === current) break;
30
41
  current = parent;
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GlobPattern } from "@effected/glob";
1
+ import { GlobPattern, GlobPatternError, GlobPatternOptions } from "@effected/glob";
2
2
  import { Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  //#region src/Descend.d.ts
4
4
  /**
@@ -75,6 +75,104 @@ declare class DescendError extends DescendError_base {
75
75
  */
76
76
  declare const descend: (pattern: GlobPattern, options: DescendOptions) => Effect.Effect<ReadonlyArray<string>, DescendError, FileSystem.FileSystem | Path.Path>;
77
77
  //#endregion
78
+ //#region src/Expand.d.ts
79
+ /**
80
+ * Options for {@link compileAndExpand}: every {@link DescendOptions} field,
81
+ * plus the glob options the pattern compiles under.
82
+ *
83
+ * @public
84
+ */
85
+ interface CompileAndExpandOptions extends DescendOptions {
86
+ /**
87
+ * The options the pattern compiles under — **required, deliberately**.
88
+ *
89
+ * @remarks
90
+ * Matching semantics (`dot` above all) are the thing two call sites most
91
+ * easily disagree about, and an optional field invites exactly that: one
92
+ * site passes `{ dot: true }`, another omits it, and the same package now
93
+ * has two glob dialects that nothing makes visible. Required means every
94
+ * call site states its dialect in its own source, so a divergence is a
95
+ * visible difference between two spellings rather than the absence of one.
96
+ * Pass `GlobPatternOptions.make({})` to mean "the defaults" — that is a
97
+ * deliberate choice being written down, not boilerplate.
98
+ */
99
+ readonly glob: GlobPatternOptions;
100
+ }
101
+ declare const GlobExpansionError_base: Schema.Class<GlobExpansionError, Schema.TaggedStruct<"GlobExpansionError", {
102
+ /** The glob pattern's source text, as handed to {@link compileAndExpand}. */
103
+ readonly pattern: Schema.String;
104
+ /** The underlying typed failure, intact: a compile guard trip or a descent failure. */
105
+ readonly cause: Schema.Union<readonly [typeof GlobPatternError, typeof DescendError]>;
106
+ }>, import("effect/Cause").YieldableError>;
107
+ /**
108
+ * Typed failure raised by {@link compileAndExpand}: the single error the
109
+ * compile+expand recipe fails with, so a caller catches one tag rather than
110
+ * folding two error channels by hand.
111
+ *
112
+ * @remarks
113
+ * One tag, two genuinely different causes — "your pattern is malformed" and
114
+ * "that directory is unreadable" are different problems with different fixes,
115
+ * so `cause` keeps the underlying typed error intact
116
+ * rather than flattening it into a string. Discriminate on `cause._tag`
117
+ * (`"GlobPatternError"` vs `"DescendError"`), or read
118
+ * {@link GlobExpansionError.stage} when only the phase matters; either way the
119
+ * original payload — a guard's `limit`/`actual`, a descent's `path` — is still
120
+ * there. `cause` is also the native `Error` cause, so error chaining and
121
+ * stack-printing work without extra wiring.
122
+ *
123
+ * @public
124
+ */
125
+ declare class GlobExpansionError extends GlobExpansionError_base {
126
+ /**
127
+ * Which phase failed — `"compile"` when the pattern itself was rejected,
128
+ * `"descend"` when the filesystem walk failed. A convenience over
129
+ * `cause._tag` for callers that only need the phase.
130
+ */
131
+ get stage(): "compile" | "descend";
132
+ get message(): string;
133
+ }
134
+ /**
135
+ * Compile a glob pattern and expand it against the filesystem in one call:
136
+ * matching FILE paths relative to `options.cwd`, POSIX separators, sorted.
137
+ *
138
+ * @remarks
139
+ * The recipe form of {@link descend}. Everything `descend` documents about
140
+ * traversal holds unchanged — the literal fast-path, the negated-pattern walk
141
+ * from `cwd`, files-only matching, symlink and prune handling, `maxDepth`,
142
+ * `onUnreadable` — because this delegates to it. What this adds is the seam:
143
+ * the pattern arrives as a string, and both failure modes arrive as one
144
+ * {@link GlobExpansionError} with the underlying error preserved in `cause`.
145
+ *
146
+ * A missing base directory, a pattern that climbs above `cwd`, and a pattern
147
+ * that simply matches nothing are all an EMPTY result, not a failure — zero
148
+ * matches is a normal glob answer. Only a rejected pattern or a failed walk
149
+ * produces an error.
150
+ *
151
+ * `FileSystem` and `Path` stay in the `R` channel and are **deliberately not
152
+ * provided here**, even though hand-providing them is the friction this
153
+ * recipe otherwise removes. `FileSystem` cannot be provided — a library that
154
+ * picks its own filesystem cannot be tested against a fixture tree. Given
155
+ * that, providing `Path` internally would not save the caller a layer (they
156
+ * still supply `FileSystem`), and it would actively break win32: the walk
157
+ * would join paths POSIX-style against a caller's win32 filesystem. The
158
+ * consumer's platform layer stays the single place that choice is made.
159
+ * Provide both once at the application boundary, not per call site.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * import { compileAndExpand } from "@effected/walker"
164
+ * import { GlobPatternOptions } from "@effected/glob"
165
+ *
166
+ * const files = yield* compileAndExpand("packages/*​/src/**​/*.ts", {
167
+ * cwd: "/repo",
168
+ * glob: GlobPatternOptions.make({ dot: true })
169
+ * })
170
+ * ```
171
+ *
172
+ * @public
173
+ */
174
+ declare const compileAndExpand: (pattern: string, options: CompileAndExpandOptions) => Effect.Effect<ReadonlyArray<string>, GlobExpansionError, FileSystem.FileSystem | Path.Path>;
175
+ //#endregion
78
176
  //#region src/Walker.d.ts
79
177
  /**
80
178
  * Options for {@link Walker.ascend}.
@@ -82,7 +180,46 @@ declare const descend: (pattern: GlobPattern, options: DescendOptions) => Effect
82
180
  * @public
83
181
  */
84
182
  interface AscendOptions {
85
- /** Stop after this directory, inclusive. */
183
+ /**
184
+ * Stop after this directory, inclusive.
185
+ *
186
+ * **Must be absolute**, and is **normalized before comparison**. The ceiling
187
+ * is matched against each directory's `Path.resolve` form rather than by raw
188
+ * string equality, so a trailing separator, a `.` or `..` segment, or a
189
+ * duplicated separator all stop at the ancestor they name. Normalization is
190
+ * idempotent — an already-resolved absolute path is unchanged — so callers
191
+ * that resolve at the call site are unaffected.
192
+ *
193
+ * A **relative** ceiling is a **defect**, not a typed failure, and is never
194
+ * resolved against the process working directory. Two rules meet here:
195
+ *
196
+ * - Malformed *input* fails typed; bad *wiring* dies. A relative ceiling is
197
+ * a caller-supplied option that is statically wrong at the call site — the
198
+ * same category as a `NaN` `maxDepth` just below, not a recoverable
199
+ * environmental condition — so it dies exactly as that does.
200
+ * - **Do not "upgrade" this to a typed error.** A typed error is only loud
201
+ * if somebody handles it, and `@effected/config-file`'s resolver contract
202
+ * absorbs every failure into `Option.none()`. A typed ceiling rejection
203
+ * would be swallowed there and re-emerge as a clean-looking "no config
204
+ * found" — the silent-wrong-answer failure this whole guard exists to
205
+ * close, reappearing through a third door. `Effect.catch` does not catch
206
+ * defects, so dying is what survives that absorption.
207
+ *
208
+ * Resolving a relative ceiling would be just as bad: the same `stopAt` would
209
+ * name different directories in a lint-staged hook, a CLI run from a package
210
+ * directory, and a test runner, with no way for the caller to see which they
211
+ * got. Rejecting costs one `path.resolve` at the site that knows the answer.
212
+ * It is also why `ascend` reads `process.cwd()` nowhere.
213
+ *
214
+ * Only the CEILING is constrained — a relative `start` is fine and ascends
215
+ * to the relative root. Absoluteness is judged by the injected `Path`
216
+ * service, so the win32 layer accepts `C:\repo` and posix does not.
217
+ *
218
+ * Normalization governs the comparison only: the chain `ascend` returns is
219
+ * still the lexical one derived from `start`, unrewritten. A ceiling naming
220
+ * no ancestor of `start` never matches and the ascent runs to the
221
+ * filesystem root.
222
+ */
86
223
  readonly stopAt?: string;
87
224
  /** Hard cap on chain length. Defaults to 256. */
88
225
  readonly maxDepth?: number;
@@ -99,5 +236,5 @@ declare const Walker: {
99
236
  readonly findRoot: <E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
100
237
  };
101
238
  //#endregion
102
- export { type AscendOptions, DescendError, type DescendOptions, Walker, descend };
239
+ export { type AscendOptions, type CompileAndExpandOptions, DescendError, type DescendOptions, GlobExpansionError, Walker, compileAndExpand, descend };
103
240
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DescendError, descend } from "./Descend.js";
2
+ import { GlobExpansionError, compileAndExpand } from "./Expand.js";
2
3
  import { Walker } from "./Walker.js";
3
4
 
4
- export { DescendError, Walker, descend };
5
+ export { DescendError, GlobExpansionError, Walker, compileAndExpand, descend };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/walker",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Path traversal as Effect primitives: ascend a directory chain to the first matching candidate, or descend a tree expanding a glob against the filesystem.",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "peerDependencies": {
40
- "@effected/glob": "0.1.2",
40
+ "@effected/glob": "~0.2.0",
41
41
  "effect": "4.0.0-beta.99"
42
42
  },
43
43
  "engines": {