@vibe-agent-toolkit/utils 0.2.0-rc.5 → 0.2.0-rc.7

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 (59) hide show
  1. package/README.md +21 -6
  2. package/dist/entrypoint.d.ts +54 -0
  3. package/dist/entrypoint.d.ts.map +1 -0
  4. package/dist/entrypoint.js +71 -0
  5. package/dist/entrypoint.js.map +1 -0
  6. package/dist/env-flag.d.ts +46 -0
  7. package/dist/env-flag.d.ts.map +1 -0
  8. package/dist/env-flag.js +57 -0
  9. package/dist/env-flag.js.map +1 -0
  10. package/dist/file-crawler.d.ts +22 -0
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +74 -13
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +406 -199
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +479 -203
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +2 -2
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +4 -7
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-tracker.d.ts +40 -1
  23. package/dist/git-tracker.d.ts.map +1 -1
  24. package/dist/git-tracker.js +83 -17
  25. package/dist/git-tracker.js.map +1 -1
  26. package/dist/git-utils.d.ts +47 -2
  27. package/dist/git-utils.d.ts.map +1 -1
  28. package/dist/git-utils.js +123 -19
  29. package/dist/git-utils.js.map +1 -1
  30. package/dist/git.d.ts +1 -0
  31. package/dist/git.d.ts.map +1 -1
  32. package/dist/git.js.map +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +30 -19
  36. package/dist/index.js.map +1 -1
  37. package/dist/listing-refusal.d.ts +111 -0
  38. package/dist/listing-refusal.d.ts.map +1 -0
  39. package/dist/listing-refusal.js +105 -0
  40. package/dist/listing-refusal.js.map +1 -0
  41. package/dist/process.d.ts +7 -0
  42. package/dist/process.d.ts.map +1 -1
  43. package/dist/process.js +7 -0
  44. package/dist/process.js.map +1 -1
  45. package/dist/safe-exec.js +1 -1
  46. package/dist/safe-exec.js.map +1 -1
  47. package/dist/test-helpers.d.ts +20 -0
  48. package/dist/test-helpers.d.ts.map +1 -1
  49. package/dist/test-helpers.js +38 -2
  50. package/dist/test-helpers.js.map +1 -1
  51. package/dist/testing.d.ts +16 -0
  52. package/dist/testing.d.ts.map +1 -1
  53. package/dist/testing.js +17 -0
  54. package/dist/testing.js.map +1 -1
  55. package/eslint/README.md +16 -2
  56. package/eslint/index.cjs +15 -0
  57. package/eslint/index.d.cts +7 -4
  58. package/eslint/rules/no-fragile-entrypoint-guard.cjs +215 -0
  59. package/package.json +3 -3
package/dist/fs-utils.js CHANGED
@@ -6,16 +6,88 @@
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". `classifyFilenameCaseFrom` is guarded by precisely that assertion (it
10
- // must reach neither `readdir` nor this pair), so a "tidy-up" back to named
11
- // imports would silently disarm the guard. The async half below already uses the
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
17
  import { toForwardSlash, toNfc } from './path-core.js';
18
18
  import { safePath } from './path-utils.js';
19
+ /**
20
+ * Turn a `readdir` rejection into the failure it actually is.
21
+ *
22
+ * ⚠️ **`isFilesystemAccessError` is deliberately NOT used here, and that is not
23
+ * an oversight.** It answers a different question — *"is this the environment's
24
+ * fault or a bug in our code?"* — and to answer it, it deliberately groups
25
+ * `ENOENT` together with `EACCES`. That grouping IS the conflation this function
26
+ * exists to undo, so reusing the predicate would reinstate the defect while
27
+ * looking like sharing.
28
+ *
29
+ * Anything that is not a recognised *absence* errno reads as unreadable,
30
+ * including an error carrying no errno at all: "I could not ask" is the answer
31
+ * that fabricates no finding, and an unrecognised failure has not established
32
+ * that the directory is missing.
33
+ *
34
+ * @param error - Whatever `fs.readdir` rejected with
35
+ * @returns The listing outcome that error stands for
36
+ */
37
+ export function listingFailure(error) {
38
+ const code = typeof error === 'object' && error !== null && 'code' in error
39
+ ? error.code
40
+ : undefined;
41
+ // `ENOTDIR` is absence too: a path component that is a file is a directory
42
+ // that does not exist, which is exactly what the caller has to report.
43
+ if (code === 'ENOENT' || code === 'ENOTDIR')
44
+ return { outcome: 'absent' };
45
+ return { outcome: 'unreadable', code: typeof code === 'string' ? code : 'UNKNOWN' };
46
+ }
47
+ /**
48
+ * Refusal errnos that a *re-ask* can legitimately answer differently.
49
+ *
50
+ * ⚠️ **This set decides what may be MEMOIZED, which makes it a correctness
51
+ * boundary rather than a taxonomy.** `EACCES` (a mode bit) and `ELOOP` (a
52
+ * committed symlink cycle) are facts about the tree: they hold for the whole
53
+ * run, re-asking buys the same refusal, and caching them is exactly what
54
+ * {@link FsLookupCache} is for. Descriptor exhaustion is not a fact about the
55
+ * tree at all — it is a fact about this process at one instant — and a memo
56
+ * that keeps one un-verifies every path under that directory for the rest of
57
+ * the run, producing a burst of findings that a re-run does not reproduce.
58
+ *
59
+ * **Deliberately short, and everything unlisted is treated as stable.** The two
60
+ * mistakes are not symmetric: memoizing a transient refusal costs a burst of
61
+ * wrong answers *within one run*, while re-asking a stable one costs an
62
+ * unbounded number of syscalls on a `--x` directory that will refuse every one
63
+ * of them — and on a dead network mount, each of those blocks. `EAGAIN` is
64
+ * included because it is literally "try again"; `ETIMEDOUT`/`ESTALE`/`EBUSY`
65
+ * are not, because a re-ask against failing hardware or a hung mount is the
66
+ * storm this set exists to avoid.
67
+ */
68
+ const TRANSIENT_LISTING_ERRNOS = new Set(['EMFILE', 'ENFILE', 'EAGAIN']);
69
+ /**
70
+ * The clause a finding prints about a refusal {@link TRANSIENT_LISTING_ERRNOS}
71
+ * calls transient — owned here, beside the list, so it describes every member.
72
+ *
73
+ * 🪤 Both consumers of `AbsenceCause.transient` used to write their own: "`X`
74
+ * is descriptor exhaustion" — true of `EMFILE`/`ENFILE` and false of `EAGAIN`,
75
+ * which is a retryable shortage of some other resource. Two lanes each carrying
76
+ * the wording for a fact this module was made the single owner of is exactly
77
+ * how the lanes come to disagree with it; the errno list and the sentence about
78
+ * it move together only if they live together.
79
+ *
80
+ * @param code - The errno the listing was refused with
81
+ * @returns A clause naming the errno and what kind of condition it is, with no
82
+ * trailing punctuation so a caller can continue the sentence
83
+ */
84
+ export function transientRefusalClause(code) {
85
+ return `${code} is a transient shortage (a descriptor or other resource this process ran out of for a moment), not a permission`;
86
+ }
87
+ /** Whether this listing failed in a way a later ask could get past. */
88
+ function isTransientRefusal(listing) {
89
+ return listing.outcome === 'unreadable' && TRANSIENT_LISTING_ERRNOS.has(listing.code);
90
+ }
19
91
  /**
20
92
  * Per-run memo for the two filesystem lookups that validation repeats on values
21
93
  * which are constant for the whole run: `realpath` of roots, and `readdir` of the
@@ -38,15 +110,15 @@ import { safePath } from './path-utils.js';
38
110
  * @example
39
111
  * ```typescript
40
112
  * const fsCache = new FsLookupCache(); // one per run
41
- * const targets = links.map((link) => link.target);
42
- * const siblingNames = await fillSiblingNames(targets, fsCache); // all the I/O, once
43
- * for (const target of targets) {
44
- * classifyFilenameCaseFrom(siblingNames, target); // pure — no syscall
113
+ * const requests = links.map((link) => ({ referrer: link.from, target: link.target }));
114
+ * const spellings = await fillPathSpellings(requests, fsCache); // all the I/O, once
115
+ * for (const { referrer, target } of requests) {
116
+ * pathSpellingFrom(spellings, referrer, target); // pure — no syscall
45
117
  * }
46
118
  * ```
47
119
  */
48
120
  export class FsLookupCache {
49
- /** Directory path → its entry names, or `null` when the directory is unreadable. */
121
+ /** Directory path → its entry names, or why the listing has none. */
50
122
  #listings = new Map();
51
123
  /** Path → its canonical path, falling back to the resolved path. */
52
124
  #realpaths = new Map();
@@ -55,6 +127,26 @@ export class FsLookupCache {
55
127
  /** Probe calls received, and how many of them reached the filesystem. */
56
128
  #probeCount = 0;
57
129
  #probeMisses = 0;
130
+ /** The listings turned into spelling indexes, built on first use. */
131
+ #spellingIndex;
132
+ /**
133
+ * The three-way spelling index over this cache's listings — one per run, for
134
+ * the same reason the listings themselves are.
135
+ *
136
+ * ⚠️ **It hangs off the cache rather than off a fill, and that is what makes
137
+ * the index pay.** A caller that judges its paths in one `fillPathSpellings`
138
+ * would be fine either way; a caller that judges them one at a time — which
139
+ * `validateLink` exists to serve — would otherwise re-index the same listing
140
+ * per path, and the cost would go straight back to
141
+ * O(paths × entries-in-that-directory) with the listing memo hiding the
142
+ * syscalls but not the work.
143
+ *
144
+ * Lazily built: a run that never judges a path allocates nothing.
145
+ */
146
+ get spellingIndex() {
147
+ this.#spellingIndex ??= new DirectorySpellingIndex(this);
148
+ return this.#spellingIndex;
149
+ }
58
150
  /**
59
151
  * Probe counters, for tests and `--debug` output.
60
152
  *
@@ -225,21 +317,63 @@ export class FsLookupCache {
225
317
  return safePath.join(await this.realpath(parent), path.basename(absolutePath));
226
318
  }
227
319
  /**
228
- * Entry names of `dirPath`, or `null` when it cannot be read (missing directory,
229
- * no permission). The unreadable answer is cached too — re-asking is the same
230
- * failed syscall.
320
+ * What `dirPath` holds, or which of the two ways the question went unanswered.
321
+ *
322
+ * A *stable* failure is cached like a success: re-asking a directory whose
323
+ * mode bits refuse us, or whose path is a symlink cycle, is the same failed
324
+ * syscall. A **transient** one is not — see {@link TRANSIENT_LISTING_ERRNOS}.
325
+ *
326
+ * ⚠️ **The transient entry is dropped only once the promise has SETTLED, and
327
+ * that timing is the whole design.** Deleting the row up front, or refusing to
328
+ * store it, would make every concurrent caller start its own `readdir` —
329
+ * turning the descriptor shortage `EMFILE` reports into a descriptor storm,
330
+ * i.e. answering the failure with more of its cause. Storing the in-flight
331
+ * promise keeps the collapse-N-callers-to-one-syscall property intact through
332
+ * the failure; evicting after it settles is what stops the *next* wave from
333
+ * inheriting a verdict about a moment that has passed.
334
+ *
335
+ * The alternative considered and rejected was a bounded retry inside this
336
+ * method. It re-issues the syscall *while the shortage is still in progress*
337
+ * (which is the storm again, only self-inflicted), it needs a backoff timer to
338
+ * be worth anything, and it hides latency inside a call every caller reads as
339
+ * a memo lookup. Letting the next ask pay one syscall is the same cost the
340
+ * cache already bounds: one per directory, per wave.
231
341
  *
232
342
  * @param dirPath - Directory to list
233
- * @returns Entry names, or `null` if the directory could not be read
343
+ * @returns The entry names, or why there are none to hand back
234
344
  */
235
345
  readdir(dirPath) {
236
346
  const cached = this.#listings.get(dirPath);
237
347
  if (cached !== undefined)
238
348
  return cached;
239
349
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
240
- const pending = fs.readdir(dirPath).catch(() => null);
350
+ const listed = fs.readdir(dirPath);
351
+ const pending = listed
352
+ .then((names) => ({ outcome: 'listed', names }))
353
+ .catch(listingFailure);
241
354
  this.#listings.set(dirPath, pending);
242
- return pending;
355
+ return this.#forgetIfTransient(dirPath, pending);
356
+ }
357
+ /**
358
+ * Hand back `pending`'s answer, dropping the memo row first when the answer is
359
+ * a *transient* refusal.
360
+ *
361
+ * The row is stored by the caller before this is reached, so the wave that
362
+ * provoked the shortage shares that one syscall; this only decides whether a
363
+ * LATER wave inherits its verdict. Identity-guarded because a later ask may
364
+ * already have installed a fresh row, and deleting that one would discard a
365
+ * listing somebody is awaiting.
366
+ *
367
+ * @param dirPath - Directory the row is filed under
368
+ * @param pending - The row itself, already stored
369
+ * @returns The same listing `pending` settles to
370
+ */
371
+ async #forgetIfTransient(dirPath, pending) {
372
+ const listing = await pending;
373
+ if (isTransientRefusal(listing) && this.#listings.get(dirPath) === pending) {
374
+ this.#listings.delete(dirPath);
375
+ }
376
+ return listing;
243
377
  }
244
378
  }
245
379
  /**
@@ -268,202 +402,346 @@ export async function copyDirectory(src, dest) {
268
402
  }
269
403
  }
270
404
  /**
271
- * List the parent directory of every path in `filePaths` — the only place I/O is
272
- * legal for this fact, and the pass that must run *before* any judging.
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.
405
+ * The refusal a `readdir` that was refused stands for — errno, directory and
406
+ * whether a re-ask could answer differently, derived ONCE beside the errno list.
287
407
  *
288
- * @param filePaths - File paths whose parent directories should be listed
289
- * @param fsCache - Per-run lookup cache (one instance per validation run)
290
- * @returns The filled table; empty input yields an empty table with no syscalls
408
+ * Shared by the spelling judge (through {@link absenceCauseFor}) and the crawl
409
+ * that defines the population (`file-crawler.ts`), so the two lanes cannot
410
+ * disagree about which refusals are transient.
411
+ *
412
+ * @param listing - A `readdir` outcome that was refused
413
+ * @param directory - The directory that was asked about
414
+ * @returns The refusal, with `directory` forward-slashed
291
415
  */
292
- export async function fillSiblingNames(filePaths, fsCache) {
293
- const parentDirs = new Set();
294
- for (const filePath of filePaths) {
295
- parentDirs.add(path.dirname(filePath));
296
- }
297
- const table = new Map();
298
- await Promise.all([...parentDirs].map(async (parentDir) => {
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;
416
+ export function directoryRefusalFor(listing, directory) {
417
+ return {
418
+ kind: 'directory_unreadable',
419
+ code: listing.code,
420
+ directory: toForwardSlash(directory),
421
+ transient: TRANSIENT_LISTING_ERRNOS.has(listing.code),
422
+ };
318
423
  }
319
424
  /**
320
- * Read the row for `filePath` out of an already-filled table. Pure.
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.
425
+ * The cause for a listing that produced no index.
328
426
  *
329
- * Internal on purpose — {@link classifyFilenameCaseFrom} is the public judge.
427
+ * @param listing - A `readdir` outcome that is not `listed`
428
+ * @param directory - The directory that was asked about
429
+ * @returns Which absence this is, and — when it is a refusal — its detail
430
+ */
431
+ function absenceCauseFor(listing, directory) {
432
+ if (listing.outcome === 'absent')
433
+ return { kind: 'no_such_entry' };
434
+ return directoryRefusalFor(listing, directory);
435
+ }
436
+ /**
437
+ * How much worse each spelling is than the one above it.
330
438
  *
331
- * @param table - Table filled by {@link fillSiblingNames}
332
- * @param filePath - Path being asked about
333
- * @returns The row: the expected basename plus the parent's entries
334
- * @throws If `table` holds no entry for the path's parent directory
439
+ * A path can be wrong in more than one way at once (`Café/Guide.md` against
440
+ * `café/guide.md`), and a report has to pick one verdict. The worst component
441
+ * wins: a case mismatch is broken on more machines than a normalization
442
+ * mismatch is, so reporting the milder one would understate what the author has
443
+ * to fix. The corrected path is carried either way, so nothing is lost.
335
444
  */
336
- export function siblingNamesFrom(table, filePath) {
337
- const parentDir = path.dirname(filePath);
338
- // `undefined` can only mean "absent key": a filled entry is an array or an
339
- // explicit `null`, never `undefined`.
340
- const names = table.get(parentDir);
341
- if (names === undefined) {
342
- throw new Error(`No sibling listing for directory "${parentDir}" (asked about "${filePath}"). ` +
343
- `Fill it with fillSiblingNames() before judging.`);
344
- }
345
- // Raw on this side too. This lookup reads a row; it does not judge, and
346
- // folding here would be judging — see {@link classifyFilenameCase}, which
347
- // needs the spelling the caller actually asked for in order to tell a
348
- // byte-exact hit from a fold-only one.
349
- return { expectedName: path.basename(filePath), names };
445
+ const SPELLING_RANK = {
446
+ exact: 0,
447
+ normalized: 1,
448
+ case_mismatch: 2,
449
+ };
450
+ /** Whether this build failed in a way a later build could get past. */
451
+ function isTransientlyUnreadable(indexed) {
452
+ return (indexed.index === null &&
453
+ indexed.because.kind === 'directory_unreadable' &&
454
+ indexed.because.transient);
455
+ }
456
+ /** Record an entry under whichever of the three spellings it is first for. */
457
+ function indexEntry(index, entry) {
458
+ if (!index.exact.has(entry))
459
+ index.exact.set(entry, entry);
460
+ const folded = toNfc(entry);
461
+ if (!index.nfc.has(folded))
462
+ index.nfc.set(folded, entry);
463
+ const lowered = folded.toLowerCase();
464
+ if (!index.folded.has(lowered))
465
+ index.folded.set(lowered, entry);
466
+ }
467
+ /** Ask one indexed listing for a name, under each rule in turn. */
468
+ function lookupIn(index, name) {
469
+ const exact = index.exact.get(name);
470
+ if (exact !== undefined)
471
+ return { match: 'exact', actualName: exact };
472
+ const folded = toNfc(name);
473
+ const normalized = index.nfc.get(folded);
474
+ if (normalized !== undefined)
475
+ return { match: 'normalized', actualName: normalized };
476
+ const insensitive = index.folded.get(folded.toLowerCase());
477
+ return insensitive === undefined
478
+ ? { match: 'absent', because: { kind: 'no_such_entry' } }
479
+ : { match: 'case_mismatch', actualName: insensitive };
350
480
  }
351
481
  /**
352
- * Decide whether `row.expectedName` names a real entry, and how faithfully.
353
- *
354
- * Pure: no filesystem, no cache, no path parsing — it reads only the columns it
355
- * is handed, which is what makes hand-written listings a legitimate test input.
356
- * Both columns arrive **raw**, exactly as `readdir` and `path.basename` produced
357
- * them; this function owns every comparison rule, so nothing upstream can
358
- * disagree with it about what "the same filename" means.
359
- *
360
- * **Three passes, strictly in this order, first match wins — the order IS the
361
- * contract**, because each pass accepts a strictly weaker notion of sameness and
362
- * a weaker pass reached first would mislabel a file that is genuinely there:
363
- *
364
- * 1. **byte-exact** `entry === expectedName`. On a case-insensitive filesystem a
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
482
+ * Every directory a run asks about, listed once and indexed once.
483
+ *
484
+ * ⚠️ **It owns the listings and never hands one out.** That is deliberate: the
485
+ * defect it replaced was a per-path scan over a shared raw array, and an
486
+ * implementation that cannot reach the array cannot scan it. The only ways to
487
+ * ask a question are {@link DirectorySpellingIndex.lookup} and
488
+ * {@link DirectorySpellingIndex.judgePath}, both `Map.get` over an index built
489
+ * at most once per directory — {@link DirectorySpellingIndex.directoriesIndexed}
490
+ * and {@link DirectorySpellingIndex.entriesIndexed} are what a test counts to
491
+ * prove the work did not go back to being per-path.
492
+ *
493
+ * **Instance-per-run, like the {@link FsLookupCache} it borrows** — it holds a
494
+ * snapshot of directory contents and must not outlive the run that took it.
407
495
  */
408
- export function classifyFilenameCase(row) {
409
- const { expectedName, names } = row;
410
- if (names === null) {
411
- // Parent directory doesn't exist (or can't be read).
412
- return { exists: false, actualName: null, match: 'absent' };
496
+ export class DirectorySpellingIndex {
497
+ #fsCache;
498
+ /** Directory → its index, or why it has none. */
499
+ #indexes = new Map();
500
+ #directoriesIndexed = 0;
501
+ #entriesIndexed = 0;
502
+ constructor(fsCache) {
503
+ this.#fsCache = fsCache;
413
504
  }
414
- // Pass 1 — byte-exact.
415
- // Tested against `undefined` rather than for truthiness: `readdir` never
416
- // yields an empty entry name, but hand-written rows are this function's
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' };
505
+ /**
506
+ * How many times a listing was turned into an index.
507
+ *
508
+ * Counted at the BUILD, not as `#indexes.size`: the size is the number of
509
+ * distinct directories asked about, which stays put even if every lookup
510
+ * rebuilds — the exact regression this number exists to catch.
511
+ */
512
+ get directoriesIndexed() {
513
+ return this.#directoriesIndexed;
423
514
  }
424
- // Pass 2 — equal only after NFC folding. Reached only when pass 1 missed, so
425
- // an all-ASCII corpus never pays for it.
426
- const foldedExpected = toNfc(expectedName);
427
- const normalizedMatch = names.find(entry => toNfc(entry) === foldedExpected);
428
- if (normalizedMatch !== undefined) {
429
- return { exists: true, actualName: normalizedMatch, match: 'normalized' };
515
+ /** How many directory entries were examined, across every index built. */
516
+ get entriesIndexed() {
517
+ return this.#entriesIndexed;
430
518
  }
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' };
519
+ /** Every directory that has been listed, for never-reached-above-the-root pins. */
520
+ get indexedDirectories() {
521
+ return [...this.#indexes.keys()];
522
+ }
523
+ /**
524
+ * Ask what `directory` really calls `name`.
525
+ *
526
+ * @param directory - Absolute path of the directory to ask about
527
+ * @param name - One path component, spelled as the caller asked for it
528
+ * @returns Which rule matched and the entry's own spelling, or `absent`
529
+ */
530
+ async lookup(directory, name) {
531
+ const indexed = await this.#indexFor(directory);
532
+ return indexed.index === null
533
+ ? { match: 'absent', because: indexed.because }
534
+ : lookupIn(indexed.index, name);
535
+ }
536
+ /**
537
+ * Judge every component of `resolvedPath`, from `root` down.
538
+ *
539
+ * Each component is judged against the directory that actually holds it —
540
+ * which is the corrected spelling of the previous component, not the
541
+ * asked-for one, so a wrong directory name does not hide a wrong filename
542
+ * beneath it.
543
+ *
544
+ * ⛔ **It never looks above `root`.** The walk starts there and only
545
+ * descends, and a path that does not live under `root` is refused outright
546
+ * rather than walked from somewhere else: a verdict that depends on a
547
+ * directory above the root is a verdict that changes when the tree is moved.
548
+ * Pick a root the caller has already enumerated, and every component below it
549
+ * is one the *reference text* contributed — exactly the ones worth judging.
550
+ *
551
+ * @param root - Absolute path of a directory known to exist, and an ancestor
552
+ * of `resolvedPath` (or `resolvedPath` itself)
553
+ * @param resolvedPath - Absolute path to judge
554
+ * @returns The worst spelling defect on the path, plus both spellings of it
555
+ * @throws If `resolvedPath` does not live at or under `root`
556
+ */
557
+ async judgePath(root, resolvedPath) {
558
+ // `safePath.relative` already answers in forward slashes; saying so out loud
559
+ // is what makes both the traversal test and the split below safe on Windows.
560
+ const askedPath = toForwardSlash(safePath.relative(root, resolvedPath));
561
+ // The root itself: the caller enumerated it to get here, so it resolves,
562
+ // and there is no component to judge. Asking would mean listing its PARENT.
563
+ if (askedPath === '')
564
+ return { match: 'exact', askedPath, actualPath: askedPath };
565
+ // Tested as a whole SEGMENT rather than as a prefix: `startsWith('..')`
566
+ // would refuse a real directory named `..cache`.
567
+ const segments = toForwardSlash(askedPath).split('/');
568
+ if (segments[0] === '..') {
569
+ throw new Error(`Path spelling asked about "${askedPath}", which is above the walk root "${root}". ` +
570
+ `A verdict that depends on a directory above the root changes when the tree moves.`);
571
+ }
572
+ return await this.#walk(root, askedPath, segments);
573
+ }
574
+ /** The component-by-component descent behind {@link judgePath}. */
575
+ async #walk(root, askedPath, segments) {
576
+ const actual = [];
577
+ let worst = 'exact';
578
+ let directory = root;
579
+ for (const segment of segments) {
580
+ // Sequential by necessity: which directory holds the next component
581
+ // depends on how this one is really spelled. Every listing is memoized,
582
+ // so a run pays per DIRECTORY, not per path and not per component.
583
+ const found = await this.lookup(directory, segment);
584
+ if (found.match === 'absent') {
585
+ // The cause travels with the verdict rather than being re-derived: by
586
+ // the time a caller reports this, the directory that refused is
587
+ // several frames gone and nothing else can tell the two absences
588
+ // apart. So does what was learned ABOVE it — see `verified`.
589
+ return {
590
+ match: 'absent',
591
+ askedPath,
592
+ actualPath: '',
593
+ because: found.because,
594
+ verified: {
595
+ match: worst,
596
+ askedPath: segments.slice(0, actual.length).join('/'),
597
+ actualPath: actual.join('/'),
598
+ },
599
+ };
600
+ }
601
+ if (SPELLING_RANK[found.match] > SPELLING_RANK[worst])
602
+ worst = found.match;
603
+ actual.push(found.actualName);
604
+ directory = safePath.join(directory, found.actualName);
605
+ }
606
+ return { match: worst, askedPath, actualPath: actual.join('/') };
607
+ }
608
+ /**
609
+ * The index for one directory, built at most once.
610
+ *
611
+ * The promise — not the resolved value — is memoized, so two components
612
+ * resolving into the same directory concurrently share one listing and one
613
+ * build rather than racing to do both twice.
614
+ *
615
+ * ⚠️ **A transient refusal is dropped here as well as in the listing memo
616
+ * underneath, and both evictions are load-bearing.** This map caches the
617
+ * built INDEX, so evicting only `FsLookupCache`'s listing would leave the
618
+ * moment-in-time refusal pinned at precisely the layer every consumer reads —
619
+ * a fix that is real and invisible. Same settle-then-evict timing, and the
620
+ * same identity guard, for the same reason: see {@link FsLookupCache.readdir}.
621
+ */
622
+ async #indexFor(directory) {
623
+ const existing = this.#indexes.get(directory);
624
+ if (existing !== undefined)
625
+ return await existing;
626
+ const building = this.#build(directory);
627
+ this.#indexes.set(directory, building);
628
+ const indexed = await building;
629
+ if (isTransientlyUnreadable(indexed) && this.#indexes.get(directory) === building) {
630
+ this.#indexes.delete(directory);
631
+ }
632
+ return indexed;
633
+ }
634
+ /** List one directory and index every entry it holds. */
635
+ async #build(directory) {
636
+ this.#directoriesIndexed += 1;
637
+ const listing = await this.#fsCache.readdir(directory);
638
+ if (listing.outcome !== 'listed') {
639
+ // Two failures, two answers: a directory that is not there is absence,
640
+ // and a directory that refused to be listed is a question nobody got to
641
+ // ask. Mapping both to `no_such_entry` here is what used to report a
642
+ // link that opens as a missing file.
643
+ return { index: null, because: absenceCauseFor(listing, directory) };
644
+ }
645
+ const index = { exact: new Map(), nfc: new Map(), folded: new Map() };
646
+ for (const name of listing.names) {
647
+ indexEntry(index, name);
648
+ this.#entriesIndexed += 1;
649
+ }
650
+ return { index };
651
+ }
652
+ }
653
+ /**
654
+ * Where to start judging `target`, given that `referrer`'s own path came off
655
+ * the filesystem rather than out of a document.
656
+ *
657
+ * ⚠️ **The root is the deepest directory the two paths share, and that choice
658
+ * is doing real work in both directions.** Everything *above* it was enumerated
659
+ * (so judging it would compare disk against disk, and on a macOS crawl that
660
+ * routinely means reporting an NFD component nobody wrote); everything *below*
661
+ * it is what the reference text contributed, and is precisely what a
662
+ * misspelling can hide in.
663
+ *
664
+ * Falls back to the target's own parent — i.e. judging the basename alone, the
665
+ * weakest useful answer — when the two paths share no meaningful ancestor
666
+ * (different drives on Windows, or a relative path).
667
+ *
668
+ * @param referrer - Path of the file holding the reference
669
+ * @param target - Absolute path the reference resolved to
670
+ * @returns The directory to walk down from
671
+ */
672
+ export function spellingWalkRoot(referrer, target) {
673
+ const referrerDir = toForwardSlash(path.dirname(referrer)).split('/');
674
+ const targetDir = toForwardSlash(path.dirname(target)).split('/');
675
+ let shared = 0;
676
+ while (shared < referrerDir.length &&
677
+ shared < targetDir.length &&
678
+ referrerDir[shared] === targetDir[shared]) {
679
+ shared += 1;
680
+ }
681
+ // `< 2` rather than `=== 0`: a single shared segment is the filesystem root
682
+ // (`''` on POSIX) or the drive (`C:` on Windows), and walking down from there
683
+ // would list directories no caller owns.
684
+ return shared < 2 ? path.dirname(target) : targetDir.slice(0, shared).join('/');
437
685
  }
438
686
  /**
439
- * Judge `filePath` against an already-filled {@link SiblingNamesTable}.
440
- *
441
- * This is the judging half of the two-pass shape: {@link fillSiblingNames} does
442
- * every listing first, then this runs over as many paths as you like with no
443
- * interleaved I/O.
444
- *
445
- * **The signature is not what keeps this free of I/O — a test is.** `fs-utils.ts`
446
- * imports `node:fs` and `node:fs/promises` at module scope, so this function's
447
- * module reaches the filesystem freely; taking no {@link FsLookupCache} and no
448
- * `fs` parameter constrains a future edit not at all, which could call
449
- * `nodeFs.statSync` on the next line and still typecheck. What actually holds the
450
- * property is `packages/utils/test/fs-utils.test.ts` →
451
- * *"judges from a filled table, reaching neither readdir nor the sync stat pair"*:
452
- * it spies `fs.readdir`, `nodeFs.existsSync` and `nodeFs.statSync` on the very
453
- * default objects this module imports, drives a positive control through each so
454
- * a zero cannot mean "the instrument never attached", and asserts the counts do
455
- * not move across judgement. If a future check needs another fact about the parent
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}
687
+ * The table key — derived in exactly one place so a filler and a judge cannot
688
+ * construct different ones for the same question.
464
689
  */
465
- export function classifyFilenameCaseFrom(table, filePath) {
466
- return classifyFilenameCase(siblingNamesFrom(table, filePath));
690
+ function spellingKey(referrer, target) {
691
+ return `${spellingWalkRoot(referrer, target)}\0${target}`;
692
+ }
693
+ /**
694
+ * Judge every request's whole path — the only place I/O is legal for this fact,
695
+ * and the pass that must run *before* any judging.
696
+ *
697
+ * Distinct (root, target) pairs are walked **concurrently**, and every listing
698
+ * they need goes through the cache's own {@link DirectorySpellingIndex}
699
+ * ({@link FsLookupCache.spellingIndex}), so a directory holding N referenced
700
+ * targets is listed once, not N times, a directory on the path to M of them is
701
+ * listed once, not M times, and a caller that fills once per path still indexes
702
+ * each directory only once for the whole run.
703
+ *
704
+ * @param requests - Targets to judge, each paired with its referring file
705
+ * @param fsCache - Per-run lookup cache (one instance per validation run)
706
+ * @returns The filled table; empty input yields an empty table with no syscalls
707
+ */
708
+ export async function fillPathSpellings(requests, fsCache) {
709
+ const distinct = new Map();
710
+ for (const request of requests) {
711
+ const key = spellingKey(request.referrer, request.target);
712
+ if (!distinct.has(key))
713
+ distinct.set(key, request);
714
+ }
715
+ const index = fsCache.spellingIndex;
716
+ const table = new Map();
717
+ await Promise.all([...distinct].map(async ([key, request]) => {
718
+ const root = spellingWalkRoot(request.referrer, request.target);
719
+ table.set(key, await index.judgePath(root, request.target));
720
+ }));
721
+ return table;
722
+ }
723
+ /**
724
+ * Read the verdict for one reference out of an already-filled table. Pure.
725
+ *
726
+ * **A miss throws rather than degrading to `absent`.** The fill set is derived
727
+ * from exactly the references the judge will be asked about, so a missing row
728
+ * is a programming error — a path judged that nobody filled. Degrading would
729
+ * report every such reference as *missing*: a wrong answer wearing the shape of
730
+ * a graceful degradation, and one no test of the verdict would catch.
731
+ *
732
+ * @param table - Table filled by {@link fillPathSpellings}
733
+ * @param referrer - The file holding the reference
734
+ * @param target - The absolute path it resolved to
735
+ * @returns How faithfully the whole path is spelled
736
+ * @throws If `table` holds no row for this (referrer, target) pair
737
+ */
738
+ export function pathSpellingFrom(table, referrer, target) {
739
+ const spelling = table.get(spellingKey(referrer, target));
740
+ if (spelling === undefined) {
741
+ throw new Error(`No path spelling for "${target}" (referenced from "${referrer}"). ` +
742
+ `Fill it with fillPathSpellings() before judging.`);
743
+ }
744
+ return spelling;
467
745
  }
468
746
  /**
469
747
  * Canonicalize every path in `paths` — the only place I/O is legal for this
@@ -472,9 +750,9 @@ export function classifyFilenameCaseFrom(table, filePath) {
472
750
  * ⚠️ **Rows are keyed by the input path string exactly as given** — not a
473
751
  * dirname, not a re-resolved form. {@link realpathFrom} looks that same string
474
752
  * up, so any normalization applied here and not there is a silent miss (a loud
475
- * one, in fact: the judge throws). Contrast {@link fillSiblingNames}, which keys
476
- * by `path.dirname` *because* many files share one listing; here the answer is
477
- * per path, so the path is the key.
753
+ * one, in fact: the judge throws). Contrast {@link fillPathSpellings}, which
754
+ * keys by (walk root, target) *because* many references share one walk; here the
755
+ * answer is per path, so the path is the key.
478
756
  *
479
757
  * Distinct paths are canonicalized **concurrently**: the shape this replaces
480
758
  * asked one path at a time at judgement time, which serialised every `realpath`
@@ -505,13 +783,11 @@ export async function fillRealpaths(paths, fsCache) {
505
783
  * column exists to remove: a regression no test of the verdict could catch,
506
784
  * because the verdict would be identical, only slower.
507
785
  *
508
- * Public, unlike {@link siblingNamesFrom}: a sibling-names row is not yet an
509
- * answer (it still needs {@link classifyFilenameCase}), whereas here the row IS
510
- * the answer — so this lookup is itself the judge for this column, and there is
511
- * nothing left to keep internal.
786
+ * The row IS the answer here — nothing further has to judge it — so this lookup
787
+ * is itself the judge for this column.
512
788
  *
513
789
  * **The signature is not what keeps this free of I/O — a test is.** As with
514
- * {@link classifyFilenameCaseFrom}, this module imports `node:fs` and
790
+ * {@link pathSpellingFrom}, this module imports `node:fs` and
515
791
  * `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
516
792
  * the parameter list prevents nothing. The guard is
517
793
  * `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching