@vibe-agent-toolkit/utils 0.1.42 → 0.2.0-rc.2

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.
Files changed (51) hide show
  1. package/README.md +19 -3
  2. package/dist/file-crawler.d.ts.map +1 -1
  3. package/dist/file-crawler.js +88 -2
  4. package/dist/file-crawler.js.map +1 -1
  5. package/dist/fs-utils.d.ts +389 -30
  6. package/dist/fs-utils.d.ts.map +1 -1
  7. package/dist/fs-utils.js +425 -56
  8. package/dist/fs-utils.js.map +1 -1
  9. package/dist/fs.d.ts +2 -1
  10. package/dist/fs.d.ts.map +1 -1
  11. package/dist/fs.js +7 -1
  12. package/dist/fs.js.map +1 -1
  13. package/dist/git-root-cache.d.ts +44 -0
  14. package/dist/git-root-cache.d.ts.map +1 -0
  15. package/dist/git-root-cache.js +68 -0
  16. package/dist/git-root-cache.js.map +1 -0
  17. package/dist/git-utils.d.ts +11 -0
  18. package/dist/git-utils.d.ts.map +1 -1
  19. package/dist/git-utils.js +28 -8
  20. package/dist/git-utils.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +37 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/numeric-args.d.ts +24 -0
  26. package/dist/numeric-args.d.ts.map +1 -0
  27. package/dist/numeric-args.js +37 -0
  28. package/dist/numeric-args.js.map +1 -0
  29. package/dist/path-core.d.ts +30 -0
  30. package/dist/path-core.d.ts.map +1 -1
  31. package/dist/path-core.js +32 -0
  32. package/dist/path-core.js.map +1 -1
  33. package/dist/path.d.ts +1 -1
  34. package/dist/path.d.ts.map +1 -1
  35. package/dist/path.js +1 -1
  36. package/dist/path.js.map +1 -1
  37. package/dist/project-utils.d.ts +7 -1
  38. package/dist/project-utils.d.ts.map +1 -1
  39. package/dist/project-utils.js +9 -1
  40. package/dist/project-utils.js.map +1 -1
  41. package/dist/test-helpers.d.ts +16 -0
  42. package/dist/test-helpers.d.ts.map +1 -1
  43. package/dist/test-helpers.js +28 -1
  44. package/dist/test-helpers.js.map +1 -1
  45. package/eslint/README.md +1 -1
  46. package/eslint/rules/dead-import.cjs +61 -11
  47. package/eslint/rules/eslint-rule-factory.cjs +16 -1
  48. package/eslint/rules/no-manual-path-normalize.cjs +24 -3
  49. package/eslint/rules/path-function-rule-factory.cjs +99 -20
  50. package/eslint/rules/prefer-startswith-over-regex.cjs +24 -1
  51. package/package.json +2 -2
@@ -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
- * for (const link of links) {
24
- * await verifyCaseSensitiveFilename(link.target, fsCache);
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
- * Canonical path for `targetPath`, falling back to `safePath.resolve()` when the
32
- * path does not exist or cannot be resolved (a non-existent file has no realpath,
33
- * and callers comparing paths still need an answer).
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
- * Verify that a file exists with the exact case-sensitive filename.
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
- * On case-insensitive filesystems (Windows, macOS), a file might be found even if
63
- * the case doesn't match. This function checks that the actual filename on disk
64
- * matches the requested path exactly (case-sensitive).
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
- * Answering requires listing the target's parent directory. Callers checking many
67
- * paths (every link in a corpus) hit the same handful of directories over and over,
68
- * so the listing comes from a caller-supplied {@link FsLookupCache}.
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
- * The cache parameter is **required rather than defaulted on purpose**: a default
71
- * would let an unmigrated call site silently keep the un-memoized behaviour, which
72
- * is a no-op wearing the shape of a fix. `new FsLookupCache()` per call reproduces
73
- * the old behaviour exactly, so migrating is mechanical — but it has to be a
74
- * decision someone made.
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 filePath - Absolute path to the file to verify
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 Object with exists flag and actual filename (or null if not found)
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
- * @example
81
- * ```typescript
82
- * // On case-insensitive filesystem with file "README.md"
83
- * const fsCache = new FsLookupCache();
84
- * const result1 = await verifyCaseSensitiveFilename('/project/README.md', fsCache);
85
- * // { exists: true, actualName: 'README.md' }
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
- * const result2 = await verifyCaseSensitiveFilename('/project/readme.md', fsCache);
88
- * // { exists: false, actualName: 'README.md' } - case mismatch!
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 declare function verifyCaseSensitiveFilename(filePath: string, fsCache: FsLookupCache): Promise<{
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
@@ -1 +1 @@
1
- {"version":3,"file":"fs-utils.d.ts","sourceRoot":"","sources":["../src/fs-utils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAQH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,aAAa;;IAOxB;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAe7C;;;;;;;OAOG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC;CASnD;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB5E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAsB,2BAA2B,CAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAgCzD"}
1
+ {"version":3,"file":"fs-utils.d.ts","sourceRoot":"","sources":["../src/fs-utils.ts"],"names":[],"mappings":"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"}