@vibe-agent-toolkit/utils 0.2.0-rc.6 → 0.2.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -9
- package/dist/asset-reference.js +3 -11
- package/dist/asset-reference.js.map +1 -1
- package/dist/crawl-timing.d.ts +3 -3
- package/dist/crawl-timing.js +3 -3
- package/dist/dirent-kind.d.ts +83 -0
- package/dist/dirent-kind.d.ts.map +1 -0
- package/dist/dirent-kind.js +129 -0
- package/dist/dirent-kind.js.map +1 -0
- package/dist/entrypoint.d.ts +54 -0
- package/dist/entrypoint.d.ts.map +1 -0
- package/dist/entrypoint.js +71 -0
- package/dist/entrypoint.js.map +1 -0
- package/dist/env-flag.d.ts +46 -0
- package/dist/env-flag.d.ts.map +1 -0
- package/dist/env-flag.js +57 -0
- package/dist/env-flag.js.map +1 -0
- package/dist/errors/errno.d.ts +51 -0
- package/dist/errors/errno.d.ts.map +1 -0
- package/dist/errors/errno.js +112 -0
- package/dist/errors/errno.js.map +1 -0
- package/dist/errors/vat-error.d.ts +60 -0
- package/dist/errors/vat-error.d.ts.map +1 -0
- package/dist/errors/vat-error.js +86 -0
- package/dist/errors/vat-error.js.map +1 -0
- package/dist/file-crawler.d.ts +22 -0
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +83 -22
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs/file-hash.d.ts.map +1 -1
- package/dist/fs/file-hash.js +0 -1
- package/dist/fs/file-hash.js.map +1 -1
- package/dist/fs-utils.d.ts +422 -217
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +535 -283
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +3 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +5 -7
- package/dist/fs.js.map +1 -1
- package/dist/git-run.d.ts +1 -1
- package/dist/git-run.js +1 -1
- package/dist/git-tracker.d.ts +41 -2
- package/dist/git-tracker.d.ts.map +1 -1
- package/dist/git-tracker.js +84 -19
- package/dist/git-tracker.js.map +1 -1
- package/dist/git-utils.d.ts +47 -2
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +123 -20
- package/dist/git-utils.js.map +1 -1
- package/dist/git.d.ts +1 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js.map +1 -1
- package/dist/gitignore-checker.d.ts.map +1 -1
- package/dist/gitignore-checker.js +9 -3
- package/dist/gitignore-checker.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -22
- package/dist/index.js.map +1 -1
- package/dist/listing-refusal.d.ts +112 -0
- package/dist/listing-refusal.d.ts.map +1 -0
- package/dist/listing-refusal.js +105 -0
- package/dist/listing-refusal.js.map +1 -0
- package/dist/path-containment.d.ts +64 -0
- package/dist/path-containment.d.ts.map +1 -0
- package/dist/path-containment.js +111 -0
- package/dist/path-containment.js.map +1 -0
- package/dist/path-core.d.ts +77 -3
- package/dist/path-core.d.ts.map +1 -1
- package/dist/path-core.js +96 -6
- package/dist/path-core.js.map +1 -1
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/path-utils.js +42 -47
- package/dist/path-utils.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -1
- package/dist/path.js.map +1 -1
- package/dist/process.d.ts +7 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +7 -0
- package/dist/process.js.map +1 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +9 -6
- package/dist/project-utils.js.map +1 -1
- package/dist/safe-exec.d.ts +2 -1
- package/dist/safe-exec.d.ts.map +1 -1
- package/dist/safe-exec.js +26 -8
- package/dist/safe-exec.js.map +1 -1
- package/dist/skill-targets.d.ts +2 -2
- package/dist/skill-targets.js +2 -2
- package/dist/skill-test/auth-resolver.d.ts +10 -2
- package/dist/skill-test/auth-resolver.d.ts.map +1 -1
- package/dist/skill-test/auth-resolver.js +26 -11
- package/dist/skill-test/auth-resolver.js.map +1 -1
- package/dist/skill-test/spawn-claude.d.ts +2 -1
- package/dist/skill-test/spawn-claude.d.ts.map +1 -1
- package/dist/skill-test/spawn-claude.js +20 -3
- package/dist/skill-test/spawn-claude.js.map +1 -1
- package/dist/skill-test/transcript.d.ts.map +1 -1
- package/dist/skill-test/transcript.js +18 -17
- package/dist/skill-test/transcript.js.map +1 -1
- package/dist/stdio-blocking.js +13 -14
- package/dist/stdio-blocking.js.map +1 -1
- package/dist/test-helpers.d.ts +56 -193
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +115 -284
- package/dist/test-helpers.js.map +1 -1
- package/dist/testing/executables.d.ts +32 -0
- package/dist/testing/executables.d.ts.map +1 -0
- package/dist/testing/executables.js +70 -0
- package/dist/testing/executables.js.map +1 -0
- package/dist/testing/hostile-tree.d.ts +79 -0
- package/dist/testing/hostile-tree.d.ts.map +1 -0
- package/dist/testing/hostile-tree.js +164 -0
- package/dist/testing/hostile-tree.js.map +1 -0
- package/dist/testing/platform-gates.d.ts +31 -0
- package/dist/testing/platform-gates.d.ts.map +1 -0
- package/dist/testing/platform-gates.js +31 -0
- package/dist/testing/platform-gates.js.map +1 -0
- package/dist/testing/temp-dir.d.ts +255 -0
- package/dist/testing/temp-dir.d.ts.map +1 -0
- package/dist/testing/temp-dir.js +363 -0
- package/dist/testing/temp-dir.js.map +1 -0
- package/dist/testing.d.ts +21 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +27 -1
- package/dist/testing.js.map +1 -1
- package/dist/text-content.d.ts.map +1 -1
- package/dist/text-content.js +7 -1
- package/dist/text-content.js.map +1 -1
- package/dist/text-file.d.ts.map +1 -1
- package/dist/text-file.js +0 -2
- package/dist/text-file.js.map +1 -1
- package/dist/timing-dump.d.ts +1 -1
- package/dist/timing-dump.d.ts.map +1 -1
- package/dist/timing-dump.js +1 -2
- package/dist/timing-dump.js.map +1 -1
- package/dist/windows-shell.d.ts +0 -1
- package/dist/windows-shell.d.ts.map +1 -1
- package/dist/windows-shell.js +0 -1
- package/dist/windows-shell.js.map +1 -1
- package/dist/zod-introspection.js +2 -2
- package/dist/zod-introspection.js.map +1 -1
- package/eslint/README.md +132 -60
- package/eslint/index.cjs +111 -147
- package/eslint/index.d.cts +14 -14
- package/eslint/rules/callee-name.cjs +23 -0
- package/eslint/rules/commands-import-boundary.cjs +148 -0
- package/eslint/rules/dead-import.cjs +1 -1
- package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
- package/eslint/rules/eslint-rule-factory.cjs +5 -2
- package/eslint/rules/exempt-path-matcher.cjs +1 -1
- package/eslint/rules/explicit-zod-strictness.cjs +120 -0
- package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
- package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
- package/eslint/rules/no-bare-symlink-in-tests.cjs +16 -3
- package/eslint/rules/no-blind-catch.cjs +171 -0
- package/eslint/rules/no-child-process-execSync.cjs +8 -0
- package/eslint/rules/no-decaying-referent.cjs +216 -0
- package/eslint/rules/no-dotdot-containment.cjs +160 -0
- package/eslint/rules/no-file-url-string-concat.cjs +4 -1
- package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
- package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
- package/eslint/rules/no-fs-promises-cp.cjs +7 -0
- package/eslint/rules/no-fs-realpathSync.cjs +8 -0
- package/eslint/rules/no-hardcoded-path-split.cjs +7 -2
- package/eslint/rules/no-io-in-unit-tier.cjs +128 -0
- package/eslint/rules/no-literal-process-exit.cjs +129 -0
- package/eslint/rules/no-manual-path-normalize.cjs +7 -2
- package/eslint/rules/no-os-tmpdir.cjs +8 -0
- package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
- package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
- package/eslint/rules/no-path-startswith.cjs +7 -2
- package/eslint/rules/no-process-exit-in-phase.cjs +12 -2
- package/eslint/rules/no-raw-node-path.cjs +563 -0
- package/eslint/rules/no-raw-text-decode.cjs +10 -1
- package/eslint/rules/no-registry-count-pin.cjs +255 -0
- package/eslint/rules/no-self-package-import.cjs +13 -2
- package/eslint/rules/no-test-scoped-functions.cjs +10 -3
- package/eslint/rules/no-unix-shell-commands.cjs +6 -2
- package/eslint/rules/no-unsafe-root-join.cjs +23 -2
- package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
- package/eslint/rules/no-version-literal.cjs +151 -0
- package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
- package/eslint/rules/require-justified-skip.cjs +8 -2
- package/eslint/rules/safe-import.cjs +1 -1
- package/package.json +5 -10
- package/eslint/rules/no-path-join.cjs +0 -14
- package/eslint/rules/no-path-relative.cjs +0 -14
- package/eslint/rules/no-path-resolve.cjs +0 -14
- package/eslint/rules/path-function-rule-factory.cjs +0 -496
package/dist/fs-utils.js
CHANGED
|
@@ -6,16 +6,92 @@
|
|
|
6
6
|
// snapshots a builtin's named ESM exports at import time, so `vi.spyOn(fs,
|
|
7
7
|
// 'existsSync')` cannot see a call made through a named binding — the spy
|
|
8
8
|
// attaches and counts zero, which reads exactly like "this function performs no
|
|
9
|
-
// I/O". `
|
|
10
|
-
// must reach neither `readdir` nor this pair), so a "tidy-up"
|
|
11
|
-
// imports would silently disarm the guard. The async half below
|
|
12
|
-
// default object for the same reason.
|
|
9
|
+
// I/O". `pathSpellingFrom` and `realpathFrom` are guarded by precisely that
|
|
10
|
+
// assertion (they must reach neither `readdir` nor this pair), so a "tidy-up"
|
|
11
|
+
// back to named imports would silently disarm the guard. The async half below
|
|
12
|
+
// already uses the default object for the same reason.
|
|
13
13
|
import nodeFs from 'node:fs';
|
|
14
14
|
import fs from 'node:fs/promises';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { promisify } from 'node:util';
|
|
17
|
+
import { FollowedWalk } from './dirent-kind.js';
|
|
18
|
+
import { isFilesystemAccessError } from './errors/errno.js';
|
|
19
|
+
import { VatError } from './errors/vat-error.js';
|
|
20
|
+
import { isUnderRoot } from './path-containment.js';
|
|
17
21
|
import { toForwardSlash, toNfc } from './path-core.js';
|
|
18
22
|
import { safePath } from './path-utils.js';
|
|
23
|
+
/**
|
|
24
|
+
* Turn a `readdir` rejection into the failure it actually is.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **`isFilesystemAccessError` is deliberately NOT used here, and that is not
|
|
27
|
+
* an oversight.** It answers a different question — *"is this the environment's
|
|
28
|
+
* fault or a bug in our code?"* — and to answer it, it deliberately groups
|
|
29
|
+
* `ENOENT` together with `EACCES`. That grouping IS the conflation this function
|
|
30
|
+
* exists to undo, so reusing the predicate would reinstate the defect while
|
|
31
|
+
* looking like sharing.
|
|
32
|
+
*
|
|
33
|
+
* Anything that is not a recognised *absence* errno reads as unreadable,
|
|
34
|
+
* including an error carrying no errno at all: "I could not ask" is the answer
|
|
35
|
+
* that fabricates no finding, and an unrecognised failure has not established
|
|
36
|
+
* that the directory is missing.
|
|
37
|
+
*
|
|
38
|
+
* @param error - Whatever `fs.readdir` rejected with
|
|
39
|
+
* @returns The listing outcome that error stands for
|
|
40
|
+
*/
|
|
41
|
+
export function listingFailure(error) {
|
|
42
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
43
|
+
? error.code
|
|
44
|
+
: undefined;
|
|
45
|
+
// `ENOTDIR` is absence too: a path component that is a file is a directory
|
|
46
|
+
// that does not exist, which is exactly what the caller has to report.
|
|
47
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
48
|
+
return { outcome: 'absent' };
|
|
49
|
+
return { outcome: 'unreadable', code: typeof code === 'string' ? code : 'UNKNOWN' };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Refusal errnos that a *re-ask* can legitimately answer differently.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ **This set decides what may be MEMOIZED, which makes it a correctness
|
|
55
|
+
* boundary rather than a taxonomy.** `EACCES` (a mode bit) and `ELOOP` (a
|
|
56
|
+
* committed symlink cycle) are facts about the tree: they hold for the whole
|
|
57
|
+
* run, re-asking buys the same refusal, and caching them is exactly what
|
|
58
|
+
* {@link FsLookupCache} is for. Descriptor exhaustion is not a fact about the
|
|
59
|
+
* tree at all — it is a fact about this process at one instant — and a memo
|
|
60
|
+
* that keeps one un-verifies every path under that directory for the rest of
|
|
61
|
+
* the run, producing a burst of findings that a re-run does not reproduce.
|
|
62
|
+
*
|
|
63
|
+
* **Deliberately short, and everything unlisted is treated as stable.** The two
|
|
64
|
+
* mistakes are not symmetric: memoizing a transient refusal costs a burst of
|
|
65
|
+
* wrong answers *within one run*, while re-asking a stable one costs an
|
|
66
|
+
* unbounded number of syscalls on a `--x` directory that will refuse every one
|
|
67
|
+
* of them — and on a dead network mount, each of those blocks. `EAGAIN` is
|
|
68
|
+
* included because it is literally "try again"; `ETIMEDOUT`/`ESTALE`/`EBUSY`
|
|
69
|
+
* are not, because a re-ask against failing hardware or a hung mount is the
|
|
70
|
+
* storm this set exists to avoid.
|
|
71
|
+
*/
|
|
72
|
+
const TRANSIENT_LISTING_ERRNOS = new Set(['EMFILE', 'ENFILE', 'EAGAIN']);
|
|
73
|
+
/**
|
|
74
|
+
* The clause a finding prints about a refusal {@link TRANSIENT_LISTING_ERRNOS}
|
|
75
|
+
* calls transient — owned here, beside the list, so it describes every member.
|
|
76
|
+
*
|
|
77
|
+
* 🪤 Both consumers of `AbsenceCause.transient` used to write their own: "`X`
|
|
78
|
+
* is descriptor exhaustion" — true of `EMFILE`/`ENFILE` and false of `EAGAIN`,
|
|
79
|
+
* which is a retryable shortage of some other resource. Two lanes each carrying
|
|
80
|
+
* the wording for a fact this module was made the single owner of is exactly
|
|
81
|
+
* how the lanes come to disagree with it; the errno list and the sentence about
|
|
82
|
+
* it move together only if they live together.
|
|
83
|
+
*
|
|
84
|
+
* @param code - The errno the listing was refused with
|
|
85
|
+
* @returns A clause naming the errno and what kind of condition it is, with no
|
|
86
|
+
* trailing punctuation so a caller can continue the sentence
|
|
87
|
+
*/
|
|
88
|
+
export function transientRefusalClause(code) {
|
|
89
|
+
return `${code} is a transient shortage (a descriptor or other resource this process ran out of for a moment), not a permission`;
|
|
90
|
+
}
|
|
91
|
+
/** Whether this listing failed in a way a later ask could get past. */
|
|
92
|
+
function isTransientRefusal(listing) {
|
|
93
|
+
return listing.outcome === 'unreadable' && TRANSIENT_LISTING_ERRNOS.has(listing.code);
|
|
94
|
+
}
|
|
19
95
|
/**
|
|
20
96
|
* Per-run memo for the two filesystem lookups that validation repeats on values
|
|
21
97
|
* which are constant for the whole run: `realpath` of roots, and `readdir` of the
|
|
@@ -38,15 +114,15 @@ import { safePath } from './path-utils.js';
|
|
|
38
114
|
* @example
|
|
39
115
|
* ```typescript
|
|
40
116
|
* const fsCache = new FsLookupCache(); // one per run
|
|
41
|
-
* const
|
|
42
|
-
* const
|
|
43
|
-
* for (const target of
|
|
44
|
-
*
|
|
117
|
+
* const requests = links.map((link) => ({ referrer: link.from, target: link.target }));
|
|
118
|
+
* const spellings = await fillPathSpellings(requests, fsCache); // all the I/O, once
|
|
119
|
+
* for (const { referrer, target } of requests) {
|
|
120
|
+
* pathSpellingFrom(spellings, referrer, target); // pure — no syscall
|
|
45
121
|
* }
|
|
46
122
|
* ```
|
|
47
123
|
*/
|
|
48
124
|
export class FsLookupCache {
|
|
49
|
-
/** Directory path → its entry names, or
|
|
125
|
+
/** Directory path → its entry names, or why the listing has none. */
|
|
50
126
|
#listings = new Map();
|
|
51
127
|
/** Path → its canonical path, falling back to the resolved path. */
|
|
52
128
|
#realpaths = new Map();
|
|
@@ -55,6 +131,26 @@ export class FsLookupCache {
|
|
|
55
131
|
/** Probe calls received, and how many of them reached the filesystem. */
|
|
56
132
|
#probeCount = 0;
|
|
57
133
|
#probeMisses = 0;
|
|
134
|
+
/** The listings turned into spelling indexes, built on first use. */
|
|
135
|
+
#spellingIndex;
|
|
136
|
+
/**
|
|
137
|
+
* The three-way spelling index over this cache's listings — one per run, for
|
|
138
|
+
* the same reason the listings themselves are.
|
|
139
|
+
*
|
|
140
|
+
* ⚠️ **It hangs off the cache rather than off a fill, and that is what makes
|
|
141
|
+
* the index pay.** A caller that judges its paths in one `fillPathSpellings`
|
|
142
|
+
* would be fine either way; a caller that judges them one at a time — which
|
|
143
|
+
* `validateLink` exists to serve — would otherwise re-index the same listing
|
|
144
|
+
* per path, and the cost would go straight back to
|
|
145
|
+
* O(paths × entries-in-that-directory) with the listing memo hiding the
|
|
146
|
+
* syscalls but not the work.
|
|
147
|
+
*
|
|
148
|
+
* Lazily built: a run that never judges a path allocates nothing.
|
|
149
|
+
*/
|
|
150
|
+
get spellingIndex() {
|
|
151
|
+
this.#spellingIndex ??= new DirectorySpellingIndex(this);
|
|
152
|
+
return this.#spellingIndex;
|
|
153
|
+
}
|
|
58
154
|
/**
|
|
59
155
|
* Probe counters, for tests and `--debug` output.
|
|
60
156
|
*
|
|
@@ -89,17 +185,23 @@ export class FsLookupCache {
|
|
|
89
185
|
if (cached !== undefined)
|
|
90
186
|
return cached;
|
|
91
187
|
this.#probeMisses++;
|
|
92
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
|
|
93
188
|
const exists = nodeFs.existsSync(targetPath);
|
|
94
189
|
let isDirectory = null;
|
|
95
190
|
if (exists) {
|
|
96
191
|
try {
|
|
97
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
|
|
98
192
|
isDirectory = nodeFs.statSync(targetPath).isDirectory();
|
|
99
193
|
}
|
|
100
|
-
catch {
|
|
101
|
-
// Present to `existsSync` but unstattable
|
|
102
|
-
//
|
|
194
|
+
catch (error) {
|
|
195
|
+
// Present to `existsSync` but unstattable — a permission change or a
|
|
196
|
+
// delete racing between the two calls. `null` records "no answer" rather
|
|
197
|
+
// than guessing `false`, which would read as "it is a file", and the
|
|
198
|
+
// link walker reports it as an unreadable target — so the refusal is
|
|
199
|
+
// SEEN, not swallowed. A bug from under the stat is not a refusal and
|
|
200
|
+
// stays loud; `isFilesystemAccessError` is the right predicate here
|
|
201
|
+
// precisely because it groups every environmental errno together and
|
|
202
|
+
// excludes a `TypeError`.
|
|
203
|
+
if (!isFilesystemAccessError(error))
|
|
204
|
+
throw error;
|
|
103
205
|
isDirectory = null;
|
|
104
206
|
}
|
|
105
207
|
}
|
|
@@ -225,25 +327,84 @@ export class FsLookupCache {
|
|
|
225
327
|
return safePath.join(await this.realpath(parent), path.basename(absolutePath));
|
|
226
328
|
}
|
|
227
329
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
330
|
+
* What `dirPath` holds, or which of the two ways the question went unanswered.
|
|
331
|
+
*
|
|
332
|
+
* A *stable* failure is cached like a success: re-asking a directory whose
|
|
333
|
+
* mode bits refuse us, or whose path is a symlink cycle, is the same failed
|
|
334
|
+
* syscall. A **transient** one is not — see {@link TRANSIENT_LISTING_ERRNOS}.
|
|
335
|
+
*
|
|
336
|
+
* ⚠️ **The transient entry is dropped only once the promise has SETTLED, and
|
|
337
|
+
* that timing is the whole design.** Deleting the row up front, or refusing to
|
|
338
|
+
* store it, would make every concurrent caller start its own `readdir` —
|
|
339
|
+
* turning the descriptor shortage `EMFILE` reports into a descriptor storm,
|
|
340
|
+
* i.e. answering the failure with more of its cause. Storing the in-flight
|
|
341
|
+
* promise keeps the collapse-N-callers-to-one-syscall property intact through
|
|
342
|
+
* the failure; evicting after it settles is what stops the *next* wave from
|
|
343
|
+
* inheriting a verdict about a moment that has passed.
|
|
344
|
+
*
|
|
345
|
+
* The alternative considered and rejected was a bounded retry inside this
|
|
346
|
+
* method. It re-issues the syscall *while the shortage is still in progress*
|
|
347
|
+
* (which is the storm again, only self-inflicted), it needs a backoff timer to
|
|
348
|
+
* be worth anything, and it hides latency inside a call every caller reads as
|
|
349
|
+
* a memo lookup. Letting the next ask pay one syscall is the same cost the
|
|
350
|
+
* cache already bounds: one per directory, per wave.
|
|
231
351
|
*
|
|
232
352
|
* @param dirPath - Directory to list
|
|
233
|
-
* @returns
|
|
353
|
+
* @returns The entry names, or why there are none to hand back
|
|
234
354
|
*/
|
|
235
355
|
readdir(dirPath) {
|
|
236
356
|
const cached = this.#listings.get(dirPath);
|
|
237
357
|
if (cached !== undefined)
|
|
238
358
|
return cached;
|
|
239
|
-
|
|
240
|
-
const pending =
|
|
359
|
+
const listed = fs.readdir(dirPath);
|
|
360
|
+
const pending = listed
|
|
361
|
+
.then((names) => ({ outcome: 'listed', names }))
|
|
362
|
+
.catch(listingFailure);
|
|
241
363
|
this.#listings.set(dirPath, pending);
|
|
242
|
-
return pending;
|
|
364
|
+
return this.#forgetIfTransient(dirPath, pending);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Hand back `pending`'s answer, dropping the memo row first when the answer is
|
|
368
|
+
* a *transient* refusal.
|
|
369
|
+
*
|
|
370
|
+
* The row is stored by the caller before this is reached, so the wave that
|
|
371
|
+
* provoked the shortage shares that one syscall; this only decides whether a
|
|
372
|
+
* LATER wave inherits its verdict. Identity-guarded because a later ask may
|
|
373
|
+
* already have installed a fresh row, and deleting that one would discard a
|
|
374
|
+
* listing somebody is awaiting.
|
|
375
|
+
*
|
|
376
|
+
* @param dirPath - Directory the row is filed under
|
|
377
|
+
* @param pending - The row itself, already stored
|
|
378
|
+
* @returns The same listing `pending` settles to
|
|
379
|
+
*/
|
|
380
|
+
async #forgetIfTransient(dirPath, pending) {
|
|
381
|
+
const listing = await pending;
|
|
382
|
+
if (isTransientRefusal(listing) && this.#listings.get(dirPath) === pending) {
|
|
383
|
+
this.#listings.delete(dirPath);
|
|
384
|
+
}
|
|
385
|
+
return listing;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Thrown when a link inside the tree being copied points outside it. */
|
|
389
|
+
export class CopyLinkEscapesSourceError extends VatError {
|
|
390
|
+
constructor(link, src) {
|
|
391
|
+
super('COPY_LINK_ESCAPES_SOURCE', `Refusing to copy ${link}: it is a symlink to a path outside ${src}. ` +
|
|
392
|
+
'A copy follows links, so this would ship content the source tree does not own — ' +
|
|
393
|
+
'replace the link with the files, or point it inside the tree.');
|
|
243
394
|
}
|
|
244
395
|
}
|
|
245
396
|
/**
|
|
246
|
-
* Recursively copy a directory
|
|
397
|
+
* Recursively copy a directory, following symlinks — contained to `src`.
|
|
398
|
+
*
|
|
399
|
+
* A link is copied as what it points at (a linked directory as its tree, a
|
|
400
|
+
* linked file as its bytes; a dangling link fails loudly in `stat`). Two
|
|
401
|
+
* refusals bound that: a link whose target is not under `src` throws
|
|
402
|
+
* {@link CopyLinkEscapesSourceError} — `scripts/etc -> /etc` used to copy
|
|
403
|
+
* `/etc` into `dist` — and a link that leads the walk back into a directory
|
|
404
|
+
* it already entered throws `DirectoryWalkRevisitedError` (`scripts/loop -> .`
|
|
405
|
+
* used to create `dest/loop/loop/…` until `ENAMETOOLONG`, writing every file
|
|
406
|
+
* at every level first). Adopter-authored trees reach this through
|
|
407
|
+
* `vat agent build`, so neither shape is exotic.
|
|
247
408
|
*
|
|
248
409
|
* @param src - Source directory path
|
|
249
410
|
* @param dest - Destination directory path
|
|
@@ -252,15 +413,33 @@ export class FsLookupCache {
|
|
|
252
413
|
* await copyDirectory('/source/dir', '/dest/dir');
|
|
253
414
|
*/
|
|
254
415
|
export async function copyDirectory(src, dest) {
|
|
255
|
-
|
|
416
|
+
const walk = new FollowedWalk();
|
|
417
|
+
walk.enter(src);
|
|
418
|
+
await copyTree(src, dest, src, walk);
|
|
419
|
+
}
|
|
420
|
+
/** One level of {@link copyDirectory}; every directory it recurses into has been `enter`ed. */
|
|
421
|
+
async function copyTree(src, dest, root, walk) {
|
|
256
422
|
await fs.mkdir(dest, { recursive: true });
|
|
257
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths from validated sources
|
|
258
423
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
259
424
|
for (const entry of entries) {
|
|
260
425
|
const srcPath = safePath.join(src, entry.name);
|
|
261
426
|
const destPath = safePath.join(dest, entry.name);
|
|
262
|
-
|
|
263
|
-
|
|
427
|
+
// (Inline rather than `direntKindFollowing`: that module imports this one.)
|
|
428
|
+
let isDirectory = entry.isDirectory();
|
|
429
|
+
if (entry.isSymbolicLink()) {
|
|
430
|
+
isDirectory = (await fs.stat(srcPath)).isDirectory();
|
|
431
|
+
// Revisit first, so a link back into the tree is named as the loop it
|
|
432
|
+
// is; then containment, so a link out is named as the escape it is.
|
|
433
|
+
if (isDirectory)
|
|
434
|
+
walk.enter(srcPath);
|
|
435
|
+
if (isUnderRoot(root, srcPath) !== 'inside')
|
|
436
|
+
throw new CopyLinkEscapesSourceError(srcPath, root);
|
|
437
|
+
}
|
|
438
|
+
else if (isDirectory) {
|
|
439
|
+
walk.enter(srcPath);
|
|
440
|
+
}
|
|
441
|
+
if (isDirectory) {
|
|
442
|
+
await copyTree(srcPath, destPath, root, walk);
|
|
264
443
|
}
|
|
265
444
|
else {
|
|
266
445
|
await fs.copyFile(srcPath, destPath);
|
|
@@ -268,202 +447,346 @@ export async function copyDirectory(src, dest) {
|
|
|
268
447
|
}
|
|
269
448
|
}
|
|
270
449
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* ⚠️ **It takes FILE paths, not directory paths, deliberately.** It derives each
|
|
275
|
-
* parent with `path.dirname` itself, so exactly one function in the system owns
|
|
276
|
-
* the key derivation and a caller cannot construct a key that
|
|
277
|
-
* {@link siblingNamesFrom} then misses. Do not "simplify" this to take
|
|
278
|
-
* directories: that hands the derivation back to every call site and reopens the
|
|
279
|
-
* silent-miss class this shape closes.
|
|
280
|
-
*
|
|
281
|
-
* Distinct parents are listed **concurrently**: the shape this replaced asked one
|
|
282
|
-
* link at a time at judgement time, which serialised every `readdir` behind the
|
|
283
|
-
* previous link's `await`. De-duplication is by parent, so N files in one
|
|
284
|
-
* directory cost one listing; the listing itself goes through
|
|
285
|
-
* {@link FsLookupCache.readdir}, which memoizes and shares in-flight promises
|
|
286
|
-
* across fills.
|
|
450
|
+
* The refusal a `readdir` that was refused stands for — errno, directory and
|
|
451
|
+
* whether a re-ask could answer differently, derived ONCE beside the errno list.
|
|
287
452
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
453
|
+
* Shared by the spelling judge (through {@link absenceCauseFor}) and the crawl
|
|
454
|
+
* that defines the population (`file-crawler.ts`), so the two lanes cannot
|
|
455
|
+
* disagree about which refusals are transient.
|
|
456
|
+
*
|
|
457
|
+
* @param listing - A `readdir` outcome that was refused
|
|
458
|
+
* @param directory - The directory that was asked about
|
|
459
|
+
* @returns The refusal, with `directory` forward-slashed
|
|
291
460
|
*/
|
|
292
|
-
export
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// Stored EXACTLY as `readdir` returned it — no Unicode folding, no copy.
|
|
300
|
-
//
|
|
301
|
-
// The fill used to fold every entry to NFC here (and `siblingNamesFrom`
|
|
302
|
-
// folded `expectedName` to match), which left the judge a pure `===` over
|
|
303
|
-
// pre-reconciled strings. It also destroyed the only evidence that could
|
|
304
|
-
// distinguish "these two spellings are the same bytes" from "these two
|
|
305
|
-
// spellings are equal only after folding" — and those are different facts
|
|
306
|
-
// on a byte-exact filesystem. Comparison semantics now live entirely in
|
|
307
|
-
// {@link classifyFilenameCase}, so the fill has no opinion to disagree
|
|
308
|
-
// with, and a hand-written row is raw `readdir` output rather than a form
|
|
309
|
-
// only the fill knew how to produce.
|
|
310
|
-
//
|
|
311
|
-
// The array is the cache's own and is deliberately not copied: the table
|
|
312
|
-
// type is `readonly string[]`, several tables may share one listing, and
|
|
313
|
-
// copying every listing per fill is exactly the per-run cost this pair
|
|
314
|
-
// exists to avoid. Treat it as immutable.
|
|
315
|
-
table.set(parentDir, await fsCache.readdir(parentDir));
|
|
316
|
-
}));
|
|
317
|
-
return table;
|
|
461
|
+
export function directoryRefusalFor(listing, directory) {
|
|
462
|
+
return {
|
|
463
|
+
kind: 'directory_unreadable',
|
|
464
|
+
code: listing.code,
|
|
465
|
+
directory: toForwardSlash(directory),
|
|
466
|
+
transient: TRANSIENT_LISTING_ERRNOS.has(listing.code),
|
|
467
|
+
};
|
|
318
468
|
}
|
|
319
469
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* **A miss throws rather than degrading to `names: null`.** The fill set is
|
|
323
|
-
* derived from exactly the paths the judge will be asked about, so a missing
|
|
324
|
-
* parent is a programming error — a path judged that nobody filled. The `null`
|
|
325
|
-
* fallback would answer it as "the directory is unreadable", which reports every
|
|
326
|
-
* file under that directory as *missing*: a wrong answer wearing the shape of a
|
|
327
|
-
* graceful degradation, and one no test of the verdict would catch.
|
|
470
|
+
* The cause for a listing that produced no index.
|
|
328
471
|
*
|
|
329
|
-
*
|
|
472
|
+
* @param listing - A `readdir` outcome that is not `listed`
|
|
473
|
+
* @param directory - The directory that was asked about
|
|
474
|
+
* @returns Which absence this is, and — when it is a refusal — its detail
|
|
475
|
+
*/
|
|
476
|
+
function absenceCauseFor(listing, directory) {
|
|
477
|
+
if (listing.outcome === 'absent')
|
|
478
|
+
return { kind: 'no_such_entry' };
|
|
479
|
+
return directoryRefusalFor(listing, directory);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* How much worse each spelling is than the one above it.
|
|
330
483
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
484
|
+
* A path can be wrong in more than one way at once (`Café/Guide.md` against
|
|
485
|
+
* `café/guide.md`), and a report has to pick one verdict. The worst component
|
|
486
|
+
* wins: a case mismatch is broken on more machines than a normalization
|
|
487
|
+
* mismatch is, so reporting the milder one would understate what the author has
|
|
488
|
+
* to fix. The corrected path is carried either way, so nothing is lost.
|
|
335
489
|
*/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
490
|
+
const SPELLING_RANK = {
|
|
491
|
+
exact: 0,
|
|
492
|
+
normalized: 1,
|
|
493
|
+
case_mismatch: 2,
|
|
494
|
+
};
|
|
495
|
+
/** Whether this build failed in a way a later build could get past. */
|
|
496
|
+
function isTransientlyUnreadable(indexed) {
|
|
497
|
+
return (indexed.index === null &&
|
|
498
|
+
indexed.because.kind === 'directory_unreadable' &&
|
|
499
|
+
indexed.because.transient);
|
|
500
|
+
}
|
|
501
|
+
/** Record an entry under whichever of the three spellings it is first for. */
|
|
502
|
+
function indexEntry(index, entry) {
|
|
503
|
+
if (!index.exact.has(entry))
|
|
504
|
+
index.exact.set(entry, entry);
|
|
505
|
+
const folded = toNfc(entry);
|
|
506
|
+
if (!index.nfc.has(folded))
|
|
507
|
+
index.nfc.set(folded, entry);
|
|
508
|
+
const lowered = folded.toLowerCase();
|
|
509
|
+
if (!index.folded.has(lowered))
|
|
510
|
+
index.folded.set(lowered, entry);
|
|
511
|
+
}
|
|
512
|
+
/** Ask one indexed listing for a name, under each rule in turn. */
|
|
513
|
+
function lookupIn(index, name) {
|
|
514
|
+
const exact = index.exact.get(name);
|
|
515
|
+
if (exact !== undefined)
|
|
516
|
+
return { match: 'exact', actualName: exact };
|
|
517
|
+
const folded = toNfc(name);
|
|
518
|
+
const normalized = index.nfc.get(folded);
|
|
519
|
+
if (normalized !== undefined)
|
|
520
|
+
return { match: 'normalized', actualName: normalized };
|
|
521
|
+
const insensitive = index.folded.get(folded.toLowerCase());
|
|
522
|
+
return insensitive === undefined
|
|
523
|
+
? { match: 'absent', because: { kind: 'no_such_entry' } }
|
|
524
|
+
: { match: 'case_mismatch', actualName: insensitive };
|
|
350
525
|
}
|
|
351
526
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* listing can hold both `readme.md` and `README.md`, in either order; asking
|
|
366
|
-
* for `README.md` must report it present regardless of which one `readdir`
|
|
367
|
-
* happened to return first. Same argument, one form weaker, for pass 2.
|
|
368
|
-
* 2. **NFC-folded** `toNfc(entry) === toNfc(expectedName)`. The two columns are
|
|
369
|
-
* different kinds of value and routinely disagree about one file: `entry` is
|
|
370
|
-
* an *enumerated* path (`readdir` hands back whatever is on disk, commonly
|
|
371
|
-
* decomposed) while `expectedName` is a path *derived from markdown link
|
|
372
|
-
* text* (composed, as an editor writes it). `é` has two encodings (NFC
|
|
373
|
-
* `U+00E9` vs NFD `e` + `U+0301`) that are `!==` and that case-folding does
|
|
374
|
-
* not reconcile, so without this pass an accented file that plainly exists
|
|
375
|
-
* was reported flatly *missing* — not even a case-mismatch hint, since that
|
|
376
|
-
* needs pass 3 to match. This is one of three sites on that seam; the class
|
|
377
|
-
* is collected in `docs/architecture/resource-scanning-and-caching.md` §3.6
|
|
378
|
-
* (ledger entry D7).
|
|
379
|
-
* 3. **case-insensitive, on the folded forms.** Folding first is required, not
|
|
380
|
-
* tidy: `toLowerCase()` does not reconcile NFC against NFD, so a name that
|
|
381
|
-
* differs in *both* case and normalization falls out as `absent` and the
|
|
382
|
-
* author loses the suggestion.
|
|
383
|
-
*
|
|
384
|
-
* ⚠️ **Passes 1 and 2 are not the same verdict, and collapsing them is a
|
|
385
|
-
* silently-wrong answer rather than a lost nicety.** The fix for D7 originally
|
|
386
|
-
* folded both sides *before* comparing, which repaired the false "missing" on
|
|
387
|
-
* macOS/APFS — and over-corrected into the opposite error on Linux/ext4, where
|
|
388
|
-
* the filesystem is byte-exact: a markdown link spelling a filename NFD while
|
|
389
|
-
* disk holds NFC genuinely 404s there, and the folded judge answered "exists,
|
|
390
|
-
* exact match, no issue". `match` is what keeps both facts: the link resolves
|
|
391
|
-
* (so it must not be reported broken), *and* it resolves only by folding (so a
|
|
392
|
-
* caller can warn). {@link classifyFilenameCaseFrom}'s consumer in
|
|
393
|
-
* `@vibe-agent-toolkit/resources` turns `'normalized'` into
|
|
394
|
-
* `LINK_NORMALIZATION_MISMATCH`. The prohibition that bounds every fold reached
|
|
395
|
-
* from here — it yields a comparison key, never a path to open — is stated once
|
|
396
|
-
* at {@link toNfc}, which is also where the reason it is not folded into
|
|
397
|
-
* `safePath.resolve` lives.
|
|
398
|
-
*
|
|
399
|
-
* **Folding is deferred to the miss path, and that is a real saving.** Pass 1
|
|
400
|
-
* calls `toNfc` zero times, so a corpus whose links all resolve byte-exactly —
|
|
401
|
-
* every pure-ASCII corpus, i.e. nearly all of them — normalizes nothing at all.
|
|
402
|
-
* The older shape folded every entry of every directory in the fill,
|
|
403
|
-
* unconditionally.
|
|
404
|
-
*
|
|
405
|
-
* @param row - The listing row, read out of a filled table by {@link siblingNamesFrom}
|
|
406
|
-
* @returns The verdict: whether it resolves, the entry really on disk, and which pass matched
|
|
527
|
+
* Every directory a run asks about, listed once and indexed once.
|
|
528
|
+
*
|
|
529
|
+
* ⚠️ **It owns the listings and never hands one out.** That is deliberate: the
|
|
530
|
+
* defect it replaced was a per-path scan over a shared raw array, and an
|
|
531
|
+
* implementation that cannot reach the array cannot scan it. The only ways to
|
|
532
|
+
* ask a question are {@link DirectorySpellingIndex.lookup} and
|
|
533
|
+
* {@link DirectorySpellingIndex.judgePath}, both `Map.get` over an index built
|
|
534
|
+
* at most once per directory — {@link DirectorySpellingIndex.directoriesIndexed}
|
|
535
|
+
* and {@link DirectorySpellingIndex.entriesIndexed} are what a test counts to
|
|
536
|
+
* prove the work did not go back to being per-path.
|
|
537
|
+
*
|
|
538
|
+
* **Instance-per-run, like the {@link FsLookupCache} it borrows** — it holds a
|
|
539
|
+
* snapshot of directory contents and must not outlive the run that took it.
|
|
407
540
|
*/
|
|
408
|
-
export
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
541
|
+
export class DirectorySpellingIndex {
|
|
542
|
+
#fsCache;
|
|
543
|
+
/** Directory → its index, or why it has none. */
|
|
544
|
+
#indexes = new Map();
|
|
545
|
+
#directoriesIndexed = 0;
|
|
546
|
+
#entriesIndexed = 0;
|
|
547
|
+
constructor(fsCache) {
|
|
548
|
+
this.#fsCache = fsCache;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* How many times a listing was turned into an index.
|
|
552
|
+
*
|
|
553
|
+
* Counted at the BUILD, not as `#indexes.size`: the size is the number of
|
|
554
|
+
* distinct directories asked about, which stays put even if every lookup
|
|
555
|
+
* rebuilds — the exact regression this number exists to catch.
|
|
556
|
+
*/
|
|
557
|
+
get directoriesIndexed() {
|
|
558
|
+
return this.#directoriesIndexed;
|
|
413
559
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// advertised input now that it is pure, and `''` is falsy — it would fall
|
|
418
|
-
// through to a later pass and come back as `actualName: ''` with the wrong
|
|
419
|
-
// `match`.
|
|
420
|
-
const exactMatch = names.find(entry => entry === expectedName);
|
|
421
|
-
if (exactMatch !== undefined) {
|
|
422
|
-
return { exists: true, actualName: exactMatch, match: 'exact' };
|
|
560
|
+
/** How many directory entries were examined, across every index built. */
|
|
561
|
+
get entriesIndexed() {
|
|
562
|
+
return this.#entriesIndexed;
|
|
423
563
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
564
|
+
/** Every directory that has been listed, for never-reached-above-the-root pins. */
|
|
565
|
+
get indexedDirectories() {
|
|
566
|
+
return [...this.#indexes.keys()];
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Ask what `directory` really calls `name`.
|
|
570
|
+
*
|
|
571
|
+
* @param directory - Absolute path of the directory to ask about
|
|
572
|
+
* @param name - One path component, spelled as the caller asked for it
|
|
573
|
+
* @returns Which rule matched and the entry's own spelling, or `absent`
|
|
574
|
+
*/
|
|
575
|
+
async lookup(directory, name) {
|
|
576
|
+
const indexed = await this.#indexFor(directory);
|
|
577
|
+
return indexed.index === null
|
|
578
|
+
? { match: 'absent', because: indexed.because }
|
|
579
|
+
: lookupIn(indexed.index, name);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Judge every component of `resolvedPath`, from `root` down.
|
|
583
|
+
*
|
|
584
|
+
* Each component is judged against the directory that actually holds it —
|
|
585
|
+
* which is the corrected spelling of the previous component, not the
|
|
586
|
+
* asked-for one, so a wrong directory name does not hide a wrong filename
|
|
587
|
+
* beneath it.
|
|
588
|
+
*
|
|
589
|
+
* ⛔ **It never looks above `root`.** The walk starts there and only
|
|
590
|
+
* descends, and a path that does not live under `root` is refused outright
|
|
591
|
+
* rather than walked from somewhere else: a verdict that depends on a
|
|
592
|
+
* directory above the root is a verdict that changes when the tree is moved.
|
|
593
|
+
* Pick a root the caller has already enumerated, and every component below it
|
|
594
|
+
* is one the *reference text* contributed — exactly the ones worth judging.
|
|
595
|
+
*
|
|
596
|
+
* @param root - Absolute path of a directory known to exist, and an ancestor
|
|
597
|
+
* of `resolvedPath` (or `resolvedPath` itself)
|
|
598
|
+
* @param resolvedPath - Absolute path to judge
|
|
599
|
+
* @returns The worst spelling defect on the path, plus both spellings of it
|
|
600
|
+
* @throws If `resolvedPath` does not live at or under `root`
|
|
601
|
+
*/
|
|
602
|
+
async judgePath(root, resolvedPath) {
|
|
603
|
+
// `safePath.relative` already answers in forward slashes; saying so out loud
|
|
604
|
+
// is what makes both the traversal test and the split below safe on Windows.
|
|
605
|
+
const askedPath = toForwardSlash(safePath.relative(root, resolvedPath));
|
|
606
|
+
// The root itself: the caller enumerated it to get here, so it resolves,
|
|
607
|
+
// and there is no component to judge. Asking would mean listing its PARENT.
|
|
608
|
+
if (askedPath === '')
|
|
609
|
+
return { match: 'exact', askedPath, actualPath: askedPath };
|
|
610
|
+
// Tested as a whole SEGMENT rather than as a prefix: `startsWith('..')`
|
|
611
|
+
// would refuse a real directory named `..cache`.
|
|
612
|
+
const segments = toForwardSlash(askedPath).split('/');
|
|
613
|
+
if (segments[0] === '..') {
|
|
614
|
+
throw new Error(`Path spelling asked about "${askedPath}", which is above the walk root "${root}". ` +
|
|
615
|
+
`A verdict that depends on a directory above the root changes when the tree moves.`);
|
|
616
|
+
}
|
|
617
|
+
return await this.#walk(root, askedPath, segments);
|
|
618
|
+
}
|
|
619
|
+
/** The component-by-component descent behind {@link judgePath}. */
|
|
620
|
+
async #walk(root, askedPath, segments) {
|
|
621
|
+
const actual = [];
|
|
622
|
+
let worst = 'exact';
|
|
623
|
+
let directory = root;
|
|
624
|
+
for (const segment of segments) {
|
|
625
|
+
// Sequential by necessity: which directory holds the next component
|
|
626
|
+
// depends on how this one is really spelled. Every listing is memoized,
|
|
627
|
+
// so a run pays per DIRECTORY, not per path and not per component.
|
|
628
|
+
const found = await this.lookup(directory, segment);
|
|
629
|
+
if (found.match === 'absent') {
|
|
630
|
+
// The cause travels with the verdict rather than being re-derived: by
|
|
631
|
+
// the time a caller reports this, the directory that refused is
|
|
632
|
+
// several frames gone and nothing else can tell the two absences
|
|
633
|
+
// apart. So does what was learned ABOVE it — see `verified`.
|
|
634
|
+
return {
|
|
635
|
+
match: 'absent',
|
|
636
|
+
askedPath,
|
|
637
|
+
actualPath: '',
|
|
638
|
+
because: found.because,
|
|
639
|
+
verified: {
|
|
640
|
+
match: worst,
|
|
641
|
+
askedPath: segments.slice(0, actual.length).join('/'),
|
|
642
|
+
actualPath: actual.join('/'),
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (SPELLING_RANK[found.match] > SPELLING_RANK[worst])
|
|
647
|
+
worst = found.match;
|
|
648
|
+
actual.push(found.actualName);
|
|
649
|
+
directory = safePath.join(directory, found.actualName);
|
|
650
|
+
}
|
|
651
|
+
return { match: worst, askedPath, actualPath: actual.join('/') };
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The index for one directory, built at most once.
|
|
655
|
+
*
|
|
656
|
+
* The promise — not the resolved value — is memoized, so two components
|
|
657
|
+
* resolving into the same directory concurrently share one listing and one
|
|
658
|
+
* build rather than racing to do both twice.
|
|
659
|
+
*
|
|
660
|
+
* ⚠️ **A transient refusal is dropped here as well as in the listing memo
|
|
661
|
+
* underneath, and both evictions are load-bearing.** This map caches the
|
|
662
|
+
* built INDEX, so evicting only `FsLookupCache`'s listing would leave the
|
|
663
|
+
* moment-in-time refusal pinned at precisely the layer every consumer reads —
|
|
664
|
+
* a fix that is real and invisible. Same settle-then-evict timing, and the
|
|
665
|
+
* same identity guard, for the same reason: see {@link FsLookupCache.readdir}.
|
|
666
|
+
*/
|
|
667
|
+
async #indexFor(directory) {
|
|
668
|
+
const existing = this.#indexes.get(directory);
|
|
669
|
+
if (existing !== undefined)
|
|
670
|
+
return await existing;
|
|
671
|
+
const building = this.#build(directory);
|
|
672
|
+
this.#indexes.set(directory, building);
|
|
673
|
+
const indexed = await building;
|
|
674
|
+
if (isTransientlyUnreadable(indexed) && this.#indexes.get(directory) === building) {
|
|
675
|
+
this.#indexes.delete(directory);
|
|
676
|
+
}
|
|
677
|
+
return indexed;
|
|
678
|
+
}
|
|
679
|
+
/** List one directory and index every entry it holds. */
|
|
680
|
+
async #build(directory) {
|
|
681
|
+
this.#directoriesIndexed += 1;
|
|
682
|
+
const listing = await this.#fsCache.readdir(directory);
|
|
683
|
+
if (listing.outcome !== 'listed') {
|
|
684
|
+
// Two failures, two answers: a directory that is not there is absence,
|
|
685
|
+
// and a directory that refused to be listed is a question nobody got to
|
|
686
|
+
// ask. Mapping both to `no_such_entry` here is what used to report a
|
|
687
|
+
// link that opens as a missing file.
|
|
688
|
+
return { index: null, because: absenceCauseFor(listing, directory) };
|
|
689
|
+
}
|
|
690
|
+
const index = { exact: new Map(), nfc: new Map(), folded: new Map() };
|
|
691
|
+
for (const name of listing.names) {
|
|
692
|
+
indexEntry(index, name);
|
|
693
|
+
this.#entriesIndexed += 1;
|
|
694
|
+
}
|
|
695
|
+
return { index };
|
|
430
696
|
}
|
|
431
|
-
// Pass 3 — case-insensitive over the folded forms.
|
|
432
|
-
const loweredExpected = foldedExpected.toLowerCase();
|
|
433
|
-
const caseInsensitiveMatch = names.find(entry => toNfc(entry).toLowerCase() === loweredExpected);
|
|
434
|
-
return caseInsensitiveMatch === undefined
|
|
435
|
-
? { exists: false, actualName: null, match: 'absent' }
|
|
436
|
-
: { exists: false, actualName: caseInsensitiveMatch, match: 'case_mismatch' };
|
|
437
697
|
}
|
|
438
698
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* directory, widen the *table* rather than reaching for `fs` here — and expect
|
|
457
|
-
* that test, not this signature, to be what stops you.
|
|
458
|
-
*
|
|
459
|
-
* @param table - Table filled by {@link fillSiblingNames}
|
|
460
|
-
* @param filePath - Absolute path to judge
|
|
461
|
-
* @returns The verdict — see {@link FilenameCaseVerdict}
|
|
462
|
-
* @throws If `table` holds no entry for the path's parent directory — see
|
|
463
|
-
* {@link siblingNamesFrom}
|
|
699
|
+
* Where to start judging `target`, given that `referrer`'s own path came off
|
|
700
|
+
* the filesystem rather than out of a document.
|
|
701
|
+
*
|
|
702
|
+
* ⚠️ **The root is the deepest directory the two paths share, and that choice
|
|
703
|
+
* is doing real work in both directions.** Everything *above* it was enumerated
|
|
704
|
+
* (so judging it would compare disk against disk, and on a macOS crawl that
|
|
705
|
+
* routinely means reporting an NFD component nobody wrote); everything *below*
|
|
706
|
+
* it is what the reference text contributed, and is precisely what a
|
|
707
|
+
* misspelling can hide in.
|
|
708
|
+
*
|
|
709
|
+
* Falls back to the target's own parent — i.e. judging the basename alone, the
|
|
710
|
+
* weakest useful answer — when the two paths share no meaningful ancestor
|
|
711
|
+
* (different drives on Windows, or a relative path).
|
|
712
|
+
*
|
|
713
|
+
* @param referrer - Path of the file holding the reference
|
|
714
|
+
* @param target - Absolute path the reference resolved to
|
|
715
|
+
* @returns The directory to walk down from
|
|
464
716
|
*/
|
|
465
|
-
export function
|
|
466
|
-
|
|
717
|
+
export function spellingWalkRoot(referrer, target) {
|
|
718
|
+
const referrerDir = toForwardSlash(path.dirname(referrer)).split('/');
|
|
719
|
+
const targetDir = toForwardSlash(path.dirname(target)).split('/');
|
|
720
|
+
let shared = 0;
|
|
721
|
+
while (shared < referrerDir.length &&
|
|
722
|
+
shared < targetDir.length &&
|
|
723
|
+
referrerDir[shared] === targetDir[shared]) {
|
|
724
|
+
shared += 1;
|
|
725
|
+
}
|
|
726
|
+
// `< 2` rather than `=== 0`: a single shared segment is the filesystem root
|
|
727
|
+
// (`''` on POSIX) or the drive (`C:` on Windows), and walking down from there
|
|
728
|
+
// would list directories no caller owns.
|
|
729
|
+
return shared < 2 ? path.dirname(target) : targetDir.slice(0, shared).join('/');
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* The table key — derived in exactly one place so a filler and a judge cannot
|
|
733
|
+
* construct different ones for the same question.
|
|
734
|
+
*/
|
|
735
|
+
function spellingKey(referrer, target) {
|
|
736
|
+
return `${spellingWalkRoot(referrer, target)}\0${target}`;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Judge every request's whole path — the only place I/O is legal for this fact,
|
|
740
|
+
* and the pass that must run *before* any judging.
|
|
741
|
+
*
|
|
742
|
+
* Distinct (root, target) pairs are walked **concurrently**, and every listing
|
|
743
|
+
* they need goes through the cache's own {@link DirectorySpellingIndex}
|
|
744
|
+
* ({@link FsLookupCache.spellingIndex}), so a directory holding N referenced
|
|
745
|
+
* targets is listed once, not N times, a directory on the path to M of them is
|
|
746
|
+
* listed once, not M times, and a caller that fills once per path still indexes
|
|
747
|
+
* each directory only once for the whole run.
|
|
748
|
+
*
|
|
749
|
+
* @param requests - Targets to judge, each paired with its referring file
|
|
750
|
+
* @param fsCache - Per-run lookup cache (one instance per validation run)
|
|
751
|
+
* @returns The filled table; empty input yields an empty table with no syscalls
|
|
752
|
+
*/
|
|
753
|
+
export async function fillPathSpellings(requests, fsCache) {
|
|
754
|
+
const distinct = new Map();
|
|
755
|
+
for (const request of requests) {
|
|
756
|
+
const key = spellingKey(request.referrer, request.target);
|
|
757
|
+
if (!distinct.has(key))
|
|
758
|
+
distinct.set(key, request);
|
|
759
|
+
}
|
|
760
|
+
const index = fsCache.spellingIndex;
|
|
761
|
+
const table = new Map();
|
|
762
|
+
await Promise.all([...distinct].map(async ([key, request]) => {
|
|
763
|
+
const root = spellingWalkRoot(request.referrer, request.target);
|
|
764
|
+
table.set(key, await index.judgePath(root, request.target));
|
|
765
|
+
}));
|
|
766
|
+
return table;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Read the verdict for one reference out of an already-filled table. Pure.
|
|
770
|
+
*
|
|
771
|
+
* **A miss throws rather than degrading to `absent`.** The fill set is derived
|
|
772
|
+
* from exactly the references the judge will be asked about, so a missing row
|
|
773
|
+
* is a programming error — a path judged that nobody filled. Degrading would
|
|
774
|
+
* report every such reference as *missing*: a wrong answer wearing the shape of
|
|
775
|
+
* a graceful degradation, and one no test of the verdict would catch.
|
|
776
|
+
*
|
|
777
|
+
* @param table - Table filled by {@link fillPathSpellings}
|
|
778
|
+
* @param referrer - The file holding the reference
|
|
779
|
+
* @param target - The absolute path it resolved to
|
|
780
|
+
* @returns How faithfully the whole path is spelled
|
|
781
|
+
* @throws If `table` holds no row for this (referrer, target) pair
|
|
782
|
+
*/
|
|
783
|
+
export function pathSpellingFrom(table, referrer, target) {
|
|
784
|
+
const spelling = table.get(spellingKey(referrer, target));
|
|
785
|
+
if (spelling === undefined) {
|
|
786
|
+
throw new Error(`No path spelling for "${target}" (referenced from "${referrer}"). ` +
|
|
787
|
+
`Fill it with fillPathSpellings() before judging.`);
|
|
788
|
+
}
|
|
789
|
+
return spelling;
|
|
467
790
|
}
|
|
468
791
|
/**
|
|
469
792
|
* Canonicalize every path in `paths` — the only place I/O is legal for this
|
|
@@ -472,9 +795,9 @@ export function classifyFilenameCaseFrom(table, filePath) {
|
|
|
472
795
|
* ⚠️ **Rows are keyed by the input path string exactly as given** — not a
|
|
473
796
|
* dirname, not a re-resolved form. {@link realpathFrom} looks that same string
|
|
474
797
|
* up, so any normalization applied here and not there is a silent miss (a loud
|
|
475
|
-
* one, in fact: the judge throws). Contrast {@link
|
|
476
|
-
* by
|
|
477
|
-
* per path, so the path is the key.
|
|
798
|
+
* one, in fact: the judge throws). Contrast {@link fillPathSpellings}, which
|
|
799
|
+
* keys by (walk root, target) *because* many references share one walk; here the
|
|
800
|
+
* answer is per path, so the path is the key.
|
|
478
801
|
*
|
|
479
802
|
* Distinct paths are canonicalized **concurrently**: the shape this replaces
|
|
480
803
|
* asked one path at a time at judgement time, which serialised every `realpath`
|
|
@@ -505,13 +828,11 @@ export async function fillRealpaths(paths, fsCache) {
|
|
|
505
828
|
* column exists to remove: a regression no test of the verdict could catch,
|
|
506
829
|
* because the verdict would be identical, only slower.
|
|
507
830
|
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* the answer — so this lookup is itself the judge for this column, and there is
|
|
511
|
-
* nothing left to keep internal.
|
|
831
|
+
* The row IS the answer here — nothing further has to judge it — so this lookup
|
|
832
|
+
* is itself the judge for this column.
|
|
512
833
|
*
|
|
513
834
|
* **The signature is not what keeps this free of I/O — a test is.** As with
|
|
514
|
-
* {@link
|
|
835
|
+
* {@link pathSpellingFrom}, this module imports `node:fs` and
|
|
515
836
|
* `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
|
|
516
837
|
* the parameter list prevents nothing. The guard is
|
|
517
838
|
* `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching
|
|
@@ -535,73 +856,4 @@ export function realpathFrom(table, filePath) {
|
|
|
535
856
|
}
|
|
536
857
|
return realPath;
|
|
537
858
|
}
|
|
538
|
-
/**
|
|
539
|
-
* Errno codes meaning "the filesystem refused this path", as opposed to a defect
|
|
540
|
-
* in our own code.
|
|
541
|
-
*
|
|
542
|
-
* Shared because two lanes need the same answer and must not drift: `vat audit`
|
|
543
|
-
* decides whether to degrade a scan over a tree it does not own, and the skill
|
|
544
|
-
* packager decides whether a `files:` match is copyable. A second, independently
|
|
545
|
-
* written list is how those two come to disagree about what counts as the
|
|
546
|
-
* environment's fault.
|
|
547
|
-
*
|
|
548
|
-
* The set is deliberately broad. An earlier, "conservative" version omitted
|
|
549
|
-
* `ENOTSUP` — the errno of the very issue this was written for — along with
|
|
550
|
-
* `EEXIST`, which an ordinary two-entry `files:` config reaches with no
|
|
551
|
-
* permissions involved at all. Both escaped raw. Every code here means the OS
|
|
552
|
-
* refused a syscall on a path; none of them can be produced by a type error or a
|
|
553
|
-
* logic bug in our own code, which is the only distinction the callers need.
|
|
554
|
-
*
|
|
555
|
-
* `EIO` and `EBUSY` are included even though they can indicate failing hardware:
|
|
556
|
-
* neither caller *swallows* anything, each reports the path and the OS message,
|
|
557
|
-
* so a dying disk surfaces once per affected path. Aborting the run instead would
|
|
558
|
-
* report less. `ENOENT` is included because a bulk scan races real filesystems —
|
|
559
|
-
* an entry listed by `readdir` can be gone by the time it is opened.
|
|
560
|
-
*/
|
|
561
|
-
const FILESYSTEM_ACCESS_ERRNOS = new Set([
|
|
562
|
-
// Permission and ownership
|
|
563
|
-
'EACCES', 'EPERM', 'EROFS',
|
|
564
|
-
// Presence and shape
|
|
565
|
-
'ENOENT', 'EEXIST', 'ENOTDIR', 'EISDIR', 'ENOTEMPTY', 'ELOOP', 'ENAMETOOLONG',
|
|
566
|
-
// Capability of the object or filesystem
|
|
567
|
-
'ENOTSUP', 'EOPNOTSUPP', 'EXDEV', 'ETXTBSY', 'EINVAL',
|
|
568
|
-
// Resource exhaustion and transient device state
|
|
569
|
-
'ENOSPC', 'EDQUOT', 'EMFILE', 'ENFILE', 'EIO', 'EBUSY', 'EAGAIN',
|
|
570
|
-
// Network filesystems
|
|
571
|
-
'ESTALE', 'ETIMEDOUT', 'EHOSTDOWN', 'ENETDOWN',
|
|
572
|
-
// Windows surfaces this for reparse points and some network paths
|
|
573
|
-
'UNKNOWN',
|
|
574
|
-
]);
|
|
575
|
-
/**
|
|
576
|
-
* Whether `error` is the filesystem refusing a path rather than a bug.
|
|
577
|
-
*
|
|
578
|
-
* Deliberately NOT `error instanceof Error`: the point of every caller is to
|
|
579
|
-
* degrade on a hostile tree, and a `TypeError` from a validator is not that.
|
|
580
|
-
* Treating one as environmental turns a real defect into a warning about
|
|
581
|
-
* whichever file it happened on — which makes a tool quietest exactly when it is
|
|
582
|
-
* most wrong.
|
|
583
|
-
*
|
|
584
|
-
* Walks `cause`, because the errno is routinely re-wrapped on its way up. The CLI
|
|
585
|
-
* config loader turns a read failure into `new Error('Failed to load config: …')`;
|
|
586
|
-
* without following the chain the predicate answered "not a filesystem error" for
|
|
587
|
-
* a plain `EACCES`, and an unreadable config aborted a whole `vat audit` run. Any
|
|
588
|
-
* layer that adds context to an OS error defeats a `code`-only check, so the check
|
|
589
|
-
* cannot be `code`-only.
|
|
590
|
-
*/
|
|
591
|
-
export function isFilesystemAccessError(error) {
|
|
592
|
-
// Bounded: a malformed `cause` chain must not become an infinite loop here.
|
|
593
|
-
for (let current = error, depth = 0; depth < 10; depth++) {
|
|
594
|
-
if (typeof current !== 'object' || current === null)
|
|
595
|
-
return false;
|
|
596
|
-
if ('code' in current) {
|
|
597
|
-
const { code } = current;
|
|
598
|
-
if (typeof code === 'string' && FILESYSTEM_ACCESS_ERRNOS.has(code))
|
|
599
|
-
return true;
|
|
600
|
-
}
|
|
601
|
-
if (!('cause' in current))
|
|
602
|
-
return false;
|
|
603
|
-
current = current.cause;
|
|
604
|
-
}
|
|
605
|
-
return false;
|
|
606
|
-
}
|
|
607
859
|
//# sourceMappingURL=fs-utils.js.map
|