@vibe-agent-toolkit/utils 0.1.42 → 0.2.0-rc.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/README.md +19 -3
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +88 -2
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +389 -30
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +425 -56
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +2 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/git-root-cache.d.ts +44 -0
- package/dist/git-root-cache.d.ts.map +1 -0
- package/dist/git-root-cache.js +68 -0
- package/dist/git-root-cache.js.map +1 -0
- package/dist/git-utils.d.ts +11 -0
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +28 -8
- package/dist/git-utils.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -2
- package/dist/index.js.map +1 -1
- package/dist/numeric-args.d.ts +24 -0
- package/dist/numeric-args.d.ts.map +1 -0
- package/dist/numeric-args.js +37 -0
- package/dist/numeric-args.js.map +1 -0
- package/dist/path-core.d.ts +30 -0
- package/dist/path-core.d.ts.map +1 -1
- package/dist/path-core.js +32 -0
- package/dist/path-core.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/project-utils.d.ts +7 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +9 -1
- package/dist/project-utils.js.map +1 -1
- package/dist/test-helpers.d.ts +16 -0
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +28 -1
- package/dist/test-helpers.js.map +1 -1
- package/eslint/README.md +1 -1
- package/eslint/rules/dead-import.cjs +61 -11
- package/eslint/rules/eslint-rule-factory.cjs +16 -1
- package/eslint/rules/no-manual-path-normalize.cjs +24 -3
- package/eslint/rules/path-function-rule-factory.cjs +99 -20
- package/eslint/rules/prefer-startswith-over-regex.cjs +24 -1
- package/package.json +2 -2
package/dist/fs-utils.d.ts
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filesystem utilities
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* What one path looked like the first time this run asked.
|
|
6
|
+
*
|
|
7
|
+
* The two fields are deliberately NOT collapsed into a single `stat` result:
|
|
8
|
+
* they record the outcome of `existsSync` and of `statSync` *separately*,
|
|
9
|
+
* because callers distinguish three states and only two of them are "the stat
|
|
10
|
+
* worked". See {@link FsLookupCache.probe}.
|
|
11
|
+
*/
|
|
12
|
+
export interface PathProbe {
|
|
13
|
+
/** `existsSync` — follows symlinks, so a dangling link reads as absent. */
|
|
14
|
+
readonly exists: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* `statSync().isDirectory()`.
|
|
17
|
+
*
|
|
18
|
+
* `null` means *no answer*, which happens two ways: the path is absent, or
|
|
19
|
+
* it exists and `statSync` threw anyway (a permission change or a delete
|
|
20
|
+
* between the two calls). Callers that must tell those apart read
|
|
21
|
+
* {@link PathProbe.exists} alongside it.
|
|
22
|
+
*/
|
|
23
|
+
readonly isDirectory: boolean | null;
|
|
24
|
+
}
|
|
25
|
+
/** How many probes a {@link FsLookupCache} answered, and how many cost syscalls. */
|
|
26
|
+
export interface PathProbeStats {
|
|
27
|
+
/** Probe calls received. */
|
|
28
|
+
readonly probes: number;
|
|
29
|
+
/** Probes that were not already memoized, i.e. that hit the filesystem. */
|
|
30
|
+
readonly misses: number;
|
|
31
|
+
}
|
|
4
32
|
/**
|
|
5
33
|
* Per-run memo for the two filesystem lookups that validation repeats on values
|
|
6
34
|
* which are constant for the whole run: `realpath` of roots, and `readdir` of the
|
|
@@ -17,20 +45,122 @@
|
|
|
17
45
|
* arbitrarily long ago. The intended lifetime is one instance per validation run,
|
|
18
46
|
* constructed as a local and collected with the run.
|
|
19
47
|
*
|
|
48
|
+
* Fill first, then judge. The loop holds no `await`, because every listing the
|
|
49
|
+
* loop could have needed was already taken:
|
|
50
|
+
*
|
|
20
51
|
* @example
|
|
21
52
|
* ```typescript
|
|
22
53
|
* const fsCache = new FsLookupCache(); // one per run
|
|
23
|
-
*
|
|
24
|
-
*
|
|
54
|
+
* const targets = links.map((link) => link.target);
|
|
55
|
+
* const siblingNames = await fillSiblingNames(targets, fsCache); // all the I/O, once
|
|
56
|
+
* for (const target of targets) {
|
|
57
|
+
* classifyFilenameCaseFrom(siblingNames, target); // pure — no syscall
|
|
25
58
|
* }
|
|
26
59
|
* ```
|
|
27
60
|
*/
|
|
28
61
|
export declare class FsLookupCache {
|
|
29
62
|
#private;
|
|
30
63
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
64
|
+
* Probe counters, for tests and `--debug` output.
|
|
65
|
+
*
|
|
66
|
+
* A memo whose tests never assert its hit count is theatre: every assertion
|
|
67
|
+
* about *values* still passes when the memo is disabled, because an
|
|
68
|
+
* always-miss cache returns the same answers — only more slowly. This is the
|
|
69
|
+
* one observable that dies when the memo does.
|
|
70
|
+
*/
|
|
71
|
+
get probeStats(): PathProbeStats;
|
|
72
|
+
/**
|
|
73
|
+
* Does this path exist, and is it a directory — asked once per run.
|
|
74
|
+
*
|
|
75
|
+
* **Both syscalls are preserved, in order, exactly as an uncached caller
|
|
76
|
+
* would make them.** `existsSync` then `statSync` is not the same as one
|
|
77
|
+
* `statSync`: the pair distinguishes "absent" from "present but unstattable",
|
|
78
|
+
* and the link walker's classifier branches differently on each. Collapsing
|
|
79
|
+
* them would be a behaviour change wearing the shape of an optimization, so
|
|
80
|
+
* this method deduplicates the pair rather than replacing it.
|
|
81
|
+
*
|
|
82
|
+
* Synchronous, unlike this class's other two lookups, because its caller (the
|
|
83
|
+
* skill link-graph walker) is synchronous throughout. One oracle answering
|
|
84
|
+
* both shapes beats a second class that differs only in colour.
|
|
85
|
+
*
|
|
86
|
+
* @param targetPath - Path to probe
|
|
87
|
+
* @returns The recorded existence/kind pair
|
|
88
|
+
*/
|
|
89
|
+
probe(targetPath: string): PathProbe;
|
|
90
|
+
/**
|
|
91
|
+
* Canonical path for `targetPath`. A path that cannot be canonicalized is
|
|
92
|
+
* answered from its **deepest existing ancestor** — that ancestor's realpath
|
|
93
|
+
* with the missing remainder re-appended — because a non-existent file has no
|
|
94
|
+
* realpath and callers comparing paths still need an answer.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ **The fallback must stay in the same NAMESPACE as the success path, which
|
|
97
|
+
* a lexical `safePath.resolve()` is not.** The only consumer of this column
|
|
98
|
+
* compares one canonical path against another (`isWithinProject` /
|
|
99
|
+
* `isWithinProjectFrom`), so an answer resolved lexically is being compared
|
|
100
|
+
* against an answer resolved through symlinks. Where the root traverses a
|
|
101
|
+
* symlink — macOS `/tmp → /private/tmp`, bind mounts, a worktree under a
|
|
102
|
+
* symlinked path — the two spellings differ and the comparison is nonsense.
|
|
103
|
+
* Measured truth table for `isWithinProject(file, root)` under a `link → real`
|
|
104
|
+
* root, before the walk:
|
|
105
|
+
*
|
|
106
|
+
* ```text
|
|
107
|
+
* existing file, symlinked root : true
|
|
108
|
+
* MISSING file, symlinked root : false ← lexical fallback, wrong namespace
|
|
109
|
+
* MISSING file, plain root : true
|
|
110
|
+
* symlink inside pointing out : false (correct either way)
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* The middle row is user-visible: a merely BROKEN root-absolute markdown link
|
|
114
|
+
* was reported as *escaping the project*. The walk fixes it without widening
|
|
115
|
+
* containment, because the ancestor is exactly where an escaping symlink
|
|
116
|
+
* lives — a missing file behind a directory link that points outside still
|
|
117
|
+
* canonicalizes outside.
|
|
118
|
+
*
|
|
119
|
+
* The recursion goes through `this.realpath(parent)`, not a private helper, so
|
|
120
|
+
* ancestors land in the same memo and share in-flight promises. A missing
|
|
121
|
+
* file's parent directory is almost always already cached, so the common case
|
|
122
|
+
* costs no extra syscall. **The fixpoint guard is mandatory**: `path.dirname`
|
|
123
|
+
* is idempotent at a root (`'/'` on posix, `'C:/'` for a drive, `'//server/share/'`
|
|
124
|
+
* for a UNC share), so without it the walk never terminates.
|
|
125
|
+
*
|
|
126
|
+
* Errno is deliberately not inspected. EACCES on an existing file and ELOOP on
|
|
127
|
+
* a symlink cycle land in the same catch as ENOENT, and for all three the
|
|
128
|
+
* ancestor's namespace is a strictly better answer than the lexical one.
|
|
129
|
+
*
|
|
130
|
+
* ⚠️ **`promisify(nodeFs.realpath)` — NOT `fs/promises.realpath`. Node ships two
|
|
131
|
+
* different realpaths and they do not agree.** `fs.realpathSync` and the
|
|
132
|
+
* `fs.realpath` *callback* form run Node's own JS implementation: an
|
|
133
|
+
* lstat/readlink walk that preserves the casing you asked for. `fs/promises.realpath`
|
|
134
|
+
* and `fs.realpath.native` call `uv_fs_realpath` (`realpath(3)` /
|
|
135
|
+
* `GetFinalPathNameByHandleW`), which reports the casing **on disk**. On a
|
|
136
|
+
* case-insensitive filesystem — macOS and Windows — those are different strings,
|
|
137
|
+
* and this column feeds *synchronous* judges that previously called
|
|
138
|
+
* `fs.realpathSync` themselves. A column that does not match `realpathSync` byte
|
|
139
|
+
* for byte flips containment verdicts and emits findings the un-refactored code
|
|
140
|
+
* does not. Measured, Node v24.13.1 / darwin, disk holding `<B>/Sub/Target.TXT`,
|
|
141
|
+
* asked for `<B>/sub/target.txt`:
|
|
142
|
+
*
|
|
143
|
+
* ```text
|
|
144
|
+
* realpathSync : <B>/sub/target.txt ← the contract
|
|
145
|
+
* promisify(fs.realpath) : <B>/sub/target.txt ✅ matches (this call)
|
|
146
|
+
* fs/promises.realpath : <B>/Sub/Target.TXT ❌ on-disk casing
|
|
147
|
+
* fs.realpath.native : <B>/Sub/Target.TXT ❌ on-disk casing
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* They also disagree on `''`, where the sync form resolves to the cwd and the
|
|
151
|
+
* native form throws `ENOENT`. **Do not "modernize" this back to `fs/promises`** —
|
|
152
|
+
* it reads tidier and silently changes output. `packages/utils/test/fs-utils.test.ts`
|
|
153
|
+
* → *"answers a mis-cased path exactly as realpathSync does, not as the native
|
|
154
|
+
* resolver does"* pins the equivalence (and skips itself on a case-sensitive
|
|
155
|
+
* filesystem, where the two routes cannot be told apart).
|
|
156
|
+
*
|
|
157
|
+
* `promisify` is applied **per call, on the default object**, not once at module
|
|
158
|
+
* scope: an eagerly captured function bypasses any `vi.spyOn(nodeFs, 'realpath')`
|
|
159
|
+
* installed after import, so this method's I/O would count zero — indistinguishable
|
|
160
|
+
* from performing none. (`fs.realpath` carries no `util.promisify.custom`, so this
|
|
161
|
+
* promisification really does get the JS implementation; it is verified, not
|
|
162
|
+
* assumed — see *"routes canonicalization through the node:fs default object"*.)
|
|
163
|
+
* The wrapper is allocated only on a cache MISS, i.e. once per actual syscall.
|
|
34
164
|
*
|
|
35
165
|
* @param targetPath - Path to canonicalize
|
|
36
166
|
* @returns Canonical path with forward slashes on every platform
|
|
@@ -57,39 +187,268 @@ export declare class FsLookupCache {
|
|
|
57
187
|
*/
|
|
58
188
|
export declare function copyDirectory(src: string, dest: string): Promise<void>;
|
|
59
189
|
/**
|
|
60
|
-
*
|
|
190
|
+
* The one fact on disk that a case-sensitivity question turns on: what the
|
|
191
|
+
* parent directory actually contains, paired with the name being asked about.
|
|
192
|
+
*
|
|
193
|
+
* A row, not an answer — {@link classifyFilenameCase} turns it into a verdict.
|
|
194
|
+
* Splitting the two is what lets the verdict be tested against listings that no
|
|
195
|
+
* filesystem will hand you on demand, entry ORDER in particular.
|
|
196
|
+
*/
|
|
197
|
+
export interface SiblingNames {
|
|
198
|
+
/**
|
|
199
|
+
* Basename being asked about, i.e. `path.basename(filePath)` — **verbatim, in
|
|
200
|
+
* whatever Unicode normalization form the path carries**. Nothing folds it on
|
|
201
|
+
* the way in; {@link classifyFilenameCase} owns every comparison rule there is.
|
|
202
|
+
*/
|
|
203
|
+
readonly expectedName: string;
|
|
204
|
+
/**
|
|
205
|
+
* The parent directory's entry names **exactly as `readdir` returned them**,
|
|
206
|
+
* or `null` when it could not be read. Raw, unfolded bytes — which is what
|
|
207
|
+
* makes "this link only resolves after normalization" a question the judge can
|
|
208
|
+
* still answer. See {@link classifyFilenameCase}.
|
|
209
|
+
*
|
|
210
|
+
* `null` is not `[]` — an unreadable or absent directory versus a readable
|
|
211
|
+
* empty one. {@link classifyFilenameCase} deliberately collapses them (both
|
|
212
|
+
* are "no such entry"), but the distinction is kept in the row because it is
|
|
213
|
+
* a *fact*, and the judge that wants it — a check that says "the directory
|
|
214
|
+
* itself is missing" rather than "the file is missing" — cannot recover it
|
|
215
|
+
* once the fill has thrown it away.
|
|
216
|
+
*/
|
|
217
|
+
readonly names: readonly string[] | null;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The materialized listing column: parent directory → that directory's entry
|
|
221
|
+
* names, or `null` when it could not be read.
|
|
222
|
+
*
|
|
223
|
+
* `null` carries exactly the meaning {@link SiblingNames.names} documents — an
|
|
224
|
+
* unreadable or absent directory, which is *not* the same fact as a readable
|
|
225
|
+
* empty one (`[]`), even though {@link classifyFilenameCase} collapses the two
|
|
226
|
+
* into one verdict.
|
|
61
227
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
228
|
+
* A *missing key* is a third thing again, and never a legal input to judgement:
|
|
229
|
+
* see {@link siblingNamesFrom}.
|
|
230
|
+
*/
|
|
231
|
+
export type SiblingNamesTable = ReadonlyMap<string, readonly string[] | null>;
|
|
232
|
+
/**
|
|
233
|
+
* List the parent directory of every path in `filePaths` — the only place I/O is
|
|
234
|
+
* legal for this fact, and the pass that must run *before* any judging.
|
|
65
235
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
236
|
+
* ⚠️ **It takes FILE paths, not directory paths, deliberately.** It derives each
|
|
237
|
+
* parent with `path.dirname` itself, so exactly one function in the system owns
|
|
238
|
+
* the key derivation and a caller cannot construct a key that
|
|
239
|
+
* {@link siblingNamesFrom} then misses. Do not "simplify" this to take
|
|
240
|
+
* directories: that hands the derivation back to every call site and reopens the
|
|
241
|
+
* silent-miss class this shape closes.
|
|
69
242
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
243
|
+
* Distinct parents are listed **concurrently**: the shape this replaced asked one
|
|
244
|
+
* link at a time at judgement time, which serialised every `readdir` behind the
|
|
245
|
+
* previous link's `await`. De-duplication is by parent, so N files in one
|
|
246
|
+
* directory cost one listing; the listing itself goes through
|
|
247
|
+
* {@link FsLookupCache.readdir}, which memoizes and shares in-flight promises
|
|
248
|
+
* across fills.
|
|
75
249
|
*
|
|
76
|
-
* @param
|
|
250
|
+
* @param filePaths - File paths whose parent directories should be listed
|
|
77
251
|
* @param fsCache - Per-run lookup cache (one instance per validation run)
|
|
78
|
-
* @returns
|
|
252
|
+
* @returns The filled table; empty input yields an empty table with no syscalls
|
|
253
|
+
*/
|
|
254
|
+
export declare function fillSiblingNames(filePaths: Iterable<string>, fsCache: FsLookupCache): Promise<SiblingNamesTable>;
|
|
255
|
+
/**
|
|
256
|
+
* Read the row for `filePath` out of an already-filled table. Pure.
|
|
79
257
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
258
|
+
* **A miss throws rather than degrading to `names: null`.** The fill set is
|
|
259
|
+
* derived from exactly the paths the judge will be asked about, so a missing
|
|
260
|
+
* parent is a programming error — a path judged that nobody filled. The `null`
|
|
261
|
+
* fallback would answer it as "the directory is unreadable", which reports every
|
|
262
|
+
* file under that directory as *missing*: a wrong answer wearing the shape of a
|
|
263
|
+
* graceful degradation, and one no test of the verdict would catch.
|
|
86
264
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
265
|
+
* Internal on purpose — {@link classifyFilenameCaseFrom} is the public judge.
|
|
266
|
+
*
|
|
267
|
+
* @param table - Table filled by {@link fillSiblingNames}
|
|
268
|
+
* @param filePath - Path being asked about
|
|
269
|
+
* @returns The row: the expected basename plus the parent's entries
|
|
270
|
+
* @throws If `table` holds no entry for the path's parent directory
|
|
271
|
+
*/
|
|
272
|
+
export declare function siblingNamesFrom(table: SiblingNamesTable, filePath: string): SiblingNames;
|
|
273
|
+
/**
|
|
274
|
+
* Which pass of {@link classifyFilenameCase} produced the answer.
|
|
275
|
+
*
|
|
276
|
+
* The three that are not `absent` are ordered by how faithfully the asked-for
|
|
277
|
+
* spelling matches disk, and every consumer that reports to a human needs the
|
|
278
|
+
* distinction: only `exact` opens on every filesystem.
|
|
90
279
|
*/
|
|
91
|
-
export
|
|
280
|
+
export type FilenameMatch =
|
|
281
|
+
/** The asked-for name and a directory entry are the same bytes. Opens anywhere. */
|
|
282
|
+
'exact'
|
|
283
|
+
/**
|
|
284
|
+
* They are different bytes that are equal after Unicode NFC folding — the same
|
|
285
|
+
* visible filename in two normalization forms. Opens on macOS/APFS and
|
|
286
|
+
* Windows; **does not open on a byte-exact filesystem** (Linux/ext4, i.e. CI
|
|
287
|
+
* and most deploy targets), where the two forms simply name different files.
|
|
288
|
+
*/
|
|
289
|
+
| 'normalized'
|
|
290
|
+
/** They differ by letter case (after folding). Opens only on a case-insensitive filesystem. */
|
|
291
|
+
| 'case_mismatch'
|
|
292
|
+
/** Nothing in the listing matches, or the directory could not be read. */
|
|
293
|
+
| 'absent';
|
|
294
|
+
/** What {@link classifyFilenameCase} decided about one asked-for filename. */
|
|
295
|
+
export interface FilenameCaseVerdict {
|
|
296
|
+
/**
|
|
297
|
+
* Whether the name resolves to an entry at all — `true` for both `exact` and
|
|
298
|
+
* `normalized`, i.e. exactly where the author's own machine opens the file.
|
|
299
|
+
* Derivable from {@link FilenameCaseVerdict.match}; kept because "does this
|
|
300
|
+
* path resolve" is the question most callers are actually asking.
|
|
301
|
+
*/
|
|
92
302
|
exists: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* The entry actually on disk, **verbatim as `readdir` returned it**, or
|
|
305
|
+
* `null` when nothing matched. Raw rather than folded on purpose: this is the
|
|
306
|
+
* string a caller suggests writing, and a folded reconstruction of an NFD
|
|
307
|
+
* entry is a spelling that does not open the file on Linux.
|
|
308
|
+
*/
|
|
93
309
|
actualName: string | null;
|
|
94
|
-
}
|
|
310
|
+
/** Which pass matched. See {@link FilenameMatch}. */
|
|
311
|
+
match: FilenameMatch;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Decide whether `row.expectedName` names a real entry, and how faithfully.
|
|
315
|
+
*
|
|
316
|
+
* Pure: no filesystem, no cache, no path parsing — it reads only the columns it
|
|
317
|
+
* is handed, which is what makes hand-written listings a legitimate test input.
|
|
318
|
+
* Both columns arrive **raw**, exactly as `readdir` and `path.basename` produced
|
|
319
|
+
* them; this function owns every comparison rule, so nothing upstream can
|
|
320
|
+
* disagree with it about what "the same filename" means.
|
|
321
|
+
*
|
|
322
|
+
* **Three passes, strictly in this order, first match wins — the order IS the
|
|
323
|
+
* contract**, because each pass accepts a strictly weaker notion of sameness and
|
|
324
|
+
* a weaker pass reached first would mislabel a file that is genuinely there:
|
|
325
|
+
*
|
|
326
|
+
* 1. **byte-exact** `entry === expectedName`. On a case-insensitive filesystem a
|
|
327
|
+
* listing can hold both `readme.md` and `README.md`, in either order; asking
|
|
328
|
+
* for `README.md` must report it present regardless of which one `readdir`
|
|
329
|
+
* happened to return first. Same argument, one form weaker, for pass 2.
|
|
330
|
+
* 2. **NFC-folded** `toNfc(entry) === toNfc(expectedName)`. `é` has two encodings
|
|
331
|
+
* (NFC `U+00E9` vs NFD `e` + `U+0301`) that are `!==` and that case-folding
|
|
332
|
+
* does not reconcile, so without this pass an accented file that plainly
|
|
333
|
+
* exists was reported flatly *missing* — not even a case-mismatch hint, since
|
|
334
|
+
* that needs pass 3 to match (ledger entry D7).
|
|
335
|
+
* 3. **case-insensitive, on the folded forms.** Folding first is required, not
|
|
336
|
+
* tidy: `toLowerCase()` does not reconcile NFC against NFD, so a name that
|
|
337
|
+
* differs in *both* case and normalization falls out as `absent` and the
|
|
338
|
+
* author loses the suggestion.
|
|
339
|
+
*
|
|
340
|
+
* ⚠️ **Passes 1 and 2 are not the same verdict, and collapsing them is a
|
|
341
|
+
* silently-wrong answer rather than a lost nicety.** The fix for D7 originally
|
|
342
|
+
* folded both sides *before* comparing, which repaired the false "missing" on
|
|
343
|
+
* macOS/APFS — and over-corrected into the opposite error on Linux/ext4, where
|
|
344
|
+
* the filesystem is byte-exact: a markdown link spelling a filename NFD while
|
|
345
|
+
* disk holds NFC genuinely 404s there, and the folded judge answered "exists,
|
|
346
|
+
* exact match, no issue". `match` is what keeps both facts: the link resolves
|
|
347
|
+
* (so it must not be reported broken), *and* it resolves only by folding (so a
|
|
348
|
+
* caller can warn). {@link classifyFilenameCaseFrom}'s consumer in
|
|
349
|
+
* `@vibe-agent-toolkit/resources` turns `'normalized'` into
|
|
350
|
+
* `LINK_NORMALIZATION_MISMATCH`.
|
|
351
|
+
*
|
|
352
|
+
* **Folding is deferred to the miss path, and that is a real saving.** Pass 1
|
|
353
|
+
* calls `toNfc` zero times, so a corpus whose links all resolve byte-exactly —
|
|
354
|
+
* every pure-ASCII corpus, i.e. nearly all of them — normalizes nothing at all.
|
|
355
|
+
* The older shape folded every entry of every directory in the fill,
|
|
356
|
+
* unconditionally.
|
|
357
|
+
*
|
|
358
|
+
* @param row - The listing row, read out of a filled table by {@link siblingNamesFrom}
|
|
359
|
+
* @returns The verdict: whether it resolves, the entry really on disk, and which pass matched
|
|
360
|
+
*/
|
|
361
|
+
export declare function classifyFilenameCase(row: SiblingNames): FilenameCaseVerdict;
|
|
362
|
+
/**
|
|
363
|
+
* Judge `filePath` against an already-filled {@link SiblingNamesTable}.
|
|
364
|
+
*
|
|
365
|
+
* This is the judging half of the two-pass shape: {@link fillSiblingNames} does
|
|
366
|
+
* every listing first, then this runs over as many paths as you like with no
|
|
367
|
+
* interleaved I/O.
|
|
368
|
+
*
|
|
369
|
+
* **The signature is not what keeps this free of I/O — a test is.** `fs-utils.ts`
|
|
370
|
+
* imports `node:fs` and `node:fs/promises` at module scope, so this function's
|
|
371
|
+
* module reaches the filesystem freely; taking no {@link FsLookupCache} and no
|
|
372
|
+
* `fs` parameter constrains a future edit not at all, which could call
|
|
373
|
+
* `nodeFs.statSync` on the next line and still typecheck. What actually holds the
|
|
374
|
+
* property is `packages/utils/test/fs-utils.test.ts` →
|
|
375
|
+
* *"judges from a filled table, reaching neither readdir nor the sync stat pair"*:
|
|
376
|
+
* it spies `fs.readdir`, `nodeFs.existsSync` and `nodeFs.statSync` on the very
|
|
377
|
+
* default objects this module imports, drives a positive control through each so
|
|
378
|
+
* a zero cannot mean "the instrument never attached", and asserts the counts do
|
|
379
|
+
* not move across judgement. If a future check needs another fact about the parent
|
|
380
|
+
* directory, widen the *table* rather than reaching for `fs` here — and expect
|
|
381
|
+
* that test, not this signature, to be what stops you.
|
|
382
|
+
*
|
|
383
|
+
* @param table - Table filled by {@link fillSiblingNames}
|
|
384
|
+
* @param filePath - Absolute path to judge
|
|
385
|
+
* @returns The verdict — see {@link FilenameCaseVerdict}
|
|
386
|
+
* @throws If `table` holds no entry for the path's parent directory — see
|
|
387
|
+
* {@link siblingNamesFrom}
|
|
388
|
+
*/
|
|
389
|
+
export declare function classifyFilenameCaseFrom(table: SiblingNamesTable, filePath: string): FilenameCaseVerdict;
|
|
390
|
+
/**
|
|
391
|
+
* The materialized realpath column: path → its canonical path.
|
|
392
|
+
*
|
|
393
|
+
* Every filled row is a string — never `null`, never `undefined`.
|
|
394
|
+
* {@link FsLookupCache.realpath} answers a path it cannot canonicalize from that
|
|
395
|
+
* path's deepest existing ancestor rather than failing, because a path that does
|
|
396
|
+
* not exist has no realpath and a caller comparing paths still needs an answer.
|
|
397
|
+
* That fallback IS the contract, and it is what lets `undefined` out of this map
|
|
398
|
+
* mean exactly one thing: *absent key*. See {@link realpathFrom}.
|
|
399
|
+
*/
|
|
400
|
+
export type RealpathTable = ReadonlyMap<string, string>;
|
|
401
|
+
/**
|
|
402
|
+
* Canonicalize every path in `paths` — the only place I/O is legal for this
|
|
403
|
+
* fact, and the pass that must run *before* any judging.
|
|
404
|
+
*
|
|
405
|
+
* ⚠️ **Rows are keyed by the input path string exactly as given** — not a
|
|
406
|
+
* dirname, not a re-resolved form. {@link realpathFrom} looks that same string
|
|
407
|
+
* up, so any normalization applied here and not there is a silent miss (a loud
|
|
408
|
+
* one, in fact: the judge throws). Contrast {@link fillSiblingNames}, which keys
|
|
409
|
+
* by `path.dirname` *because* many files share one listing; here the answer is
|
|
410
|
+
* per path, so the path is the key.
|
|
411
|
+
*
|
|
412
|
+
* Distinct paths are canonicalized **concurrently**: the shape this replaces
|
|
413
|
+
* asked one path at a time at judgement time, which serialised every `realpath`
|
|
414
|
+
* behind the previous path's `await`. De-duplication is by path, so the same
|
|
415
|
+
* path passed N times costs one syscall; the call itself goes through
|
|
416
|
+
* {@link FsLookupCache.realpath}, which memoizes and shares in-flight promises
|
|
417
|
+
* across fills.
|
|
418
|
+
*
|
|
419
|
+
* @param paths - Paths to canonicalize
|
|
420
|
+
* @param fsCache - Per-run lookup cache (one instance per validation run)
|
|
421
|
+
* @returns The filled table; empty input yields an empty table with no syscalls
|
|
422
|
+
*/
|
|
423
|
+
export declare function fillRealpaths(paths: Iterable<string>, fsCache: FsLookupCache): Promise<RealpathTable>;
|
|
424
|
+
/**
|
|
425
|
+
* Read the canonical path for `filePath` out of an already-filled table. Pure.
|
|
426
|
+
*
|
|
427
|
+
* **A miss throws rather than degrading to a recomputed realpath.** The fill set
|
|
428
|
+
* is derived from exactly the paths the judge will be asked about, so a missing
|
|
429
|
+
* key is a programming error — a path judged that nobody filled. Recomputing it
|
|
430
|
+
* would answer *correctly* and silently reintroduce the per-path syscall this
|
|
431
|
+
* column exists to remove: a regression no test of the verdict could catch,
|
|
432
|
+
* because the verdict would be identical, only slower.
|
|
433
|
+
*
|
|
434
|
+
* Public, unlike {@link siblingNamesFrom}: a sibling-names row is not yet an
|
|
435
|
+
* answer (it still needs {@link classifyFilenameCase}), whereas here the row IS
|
|
436
|
+
* the answer — so this lookup is itself the judge for this column, and there is
|
|
437
|
+
* nothing left to keep internal.
|
|
438
|
+
*
|
|
439
|
+
* **The signature is not what keeps this free of I/O — a test is.** As with
|
|
440
|
+
* {@link classifyFilenameCaseFrom}, this module imports `node:fs` and
|
|
441
|
+
* `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
|
|
442
|
+
* the parameter list prevents nothing. The guard is
|
|
443
|
+
* `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching
|
|
444
|
+
* neither the async nor the sync realpath"*, which spies `nodeFs.realpath` and
|
|
445
|
+
* `nodeFs.realpathSync` on the module default objects, proves both instruments
|
|
446
|
+
* attached with a positive control, and asserts zero calls across judgement.
|
|
447
|
+
*
|
|
448
|
+
* @param table - Table filled by {@link fillRealpaths}
|
|
449
|
+
* @param filePath - Path being asked about, as it was handed to the fill
|
|
450
|
+
* @returns The canonical path, with forward slashes on every platform
|
|
451
|
+
* @throws If `table` holds no row for `filePath`
|
|
452
|
+
*/
|
|
453
|
+
export declare function realpathFrom(table: RealpathTable, filePath: string): string;
|
|
95
454
|
//# sourceMappingURL=fs-utils.d.ts.map
|
package/dist/fs-utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fs-utils.d.ts","sourceRoot":"","sources":["../src/fs-utils.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"fs-utils.d.ts","sourceRoot":"","sources":["../src/fs-utils.ts"],"names":[],"mappings":"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"}
|