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