@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
@@ -22,6 +22,77 @@ export interface PathProbe {
22
22
  */
23
23
  readonly isDirectory: boolean | null;
24
24
  }
25
+ /**
26
+ * What one `readdir` answered: the entries, or which of the two ways it failed.
27
+ *
28
+ * ⛔ **The two failures are NOT one answer, and collapsing them is a wrong
29
+ * verdict rather than a lost nicety.** This used to be `string[] | null`, where
30
+ * one `null` meant both *"there is no such directory"* and *"I was refused"*.
31
+ * Only the first is absence. A POSIX `--x` directory (mode `0111`) is
32
+ * *traversable* — every file below it opens exactly as written — while
33
+ * `readdir` returns `EACCES`; a judge that walks a path component by component
34
+ * then declared a link that opens fine to be a missing file, and said so with
35
+ * the confident wrong diagnosis *"File not found"*. The condition to report is
36
+ * that a **directory could not be listed**, which is the caller's to decide, and
37
+ * it cannot decide what this type will not carry.
38
+ *
39
+ * The sibling proof that the distinction is real: `resources/src/okf/discovery.ts`
40
+ * already reports an unlistable subdirectory as its own `OKF_SUBDIRECTORY_UNREADABLE`
41
+ * finding rather than as a missing one.
42
+ */
43
+ export type DirectoryListing =
44
+ /** The directory was read. `names` is exactly what `readdir` handed back. */
45
+ {
46
+ readonly outcome: 'listed';
47
+ readonly names: string[];
48
+ }
49
+ /** There is no such directory (`ENOENT`), or a path component is a file (`ENOTDIR`). */
50
+ | {
51
+ readonly outcome: 'absent';
52
+ }
53
+ /**
54
+ * The directory may well hold the entry asked about; the OS refused the
55
+ * question. `code` is the errno, for a caller that reports the reason.
56
+ */
57
+ | {
58
+ readonly outcome: 'unreadable';
59
+ readonly code: string;
60
+ };
61
+ /**
62
+ * Turn a `readdir` rejection into the failure it actually is.
63
+ *
64
+ * ⚠️ **`isFilesystemAccessError` is deliberately NOT used here, and that is not
65
+ * an oversight.** It answers a different question — *"is this the environment's
66
+ * fault or a bug in our code?"* — and to answer it, it deliberately groups
67
+ * `ENOENT` together with `EACCES`. That grouping IS the conflation this function
68
+ * exists to undo, so reusing the predicate would reinstate the defect while
69
+ * looking like sharing.
70
+ *
71
+ * Anything that is not a recognised *absence* errno reads as unreadable,
72
+ * including an error carrying no errno at all: "I could not ask" is the answer
73
+ * that fabricates no finding, and an unrecognised failure has not established
74
+ * that the directory is missing.
75
+ *
76
+ * @param error - Whatever `fs.readdir` rejected with
77
+ * @returns The listing outcome that error stands for
78
+ */
79
+ export declare function listingFailure(error: unknown): DirectoryListing;
80
+ /**
81
+ * The clause a finding prints about a refusal {@link TRANSIENT_LISTING_ERRNOS}
82
+ * calls transient — owned here, beside the list, so it describes every member.
83
+ *
84
+ * 🪤 Both consumers of `AbsenceCause.transient` used to write their own: "`X`
85
+ * is descriptor exhaustion" — true of `EMFILE`/`ENFILE` and false of `EAGAIN`,
86
+ * which is a retryable shortage of some other resource. Two lanes each carrying
87
+ * the wording for a fact this module was made the single owner of is exactly
88
+ * how the lanes come to disagree with it; the errno list and the sentence about
89
+ * it move together only if they live together.
90
+ *
91
+ * @param code - The errno the listing was refused with
92
+ * @returns A clause naming the errno and what kind of condition it is, with no
93
+ * trailing punctuation so a caller can continue the sentence
94
+ */
95
+ export declare function transientRefusalClause(code: string): string;
25
96
  /** How many probes a {@link FsLookupCache} answered, and how many cost syscalls. */
26
97
  export interface PathProbeStats {
27
98
  /** Probe calls received. */
@@ -51,15 +122,30 @@ export interface PathProbeStats {
51
122
  * @example
52
123
  * ```typescript
53
124
  * const fsCache = new FsLookupCache(); // one per run
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
125
+ * const requests = links.map((link) => ({ referrer: link.from, target: link.target }));
126
+ * const spellings = await fillPathSpellings(requests, fsCache); // all the I/O, once
127
+ * for (const { referrer, target } of requests) {
128
+ * pathSpellingFrom(spellings, referrer, target); // pure — no syscall
58
129
  * }
59
130
  * ```
60
131
  */
61
132
  export declare class FsLookupCache {
62
133
  #private;
134
+ /**
135
+ * The three-way spelling index over this cache's listings — one per run, for
136
+ * the same reason the listings themselves are.
137
+ *
138
+ * ⚠️ **It hangs off the cache rather than off a fill, and that is what makes
139
+ * the index pay.** A caller that judges its paths in one `fillPathSpellings`
140
+ * would be fine either way; a caller that judges them one at a time — which
141
+ * `validateLink` exists to serve — would otherwise re-index the same listing
142
+ * per path, and the cost would go straight back to
143
+ * O(paths × entries-in-that-directory) with the listing memo hiding the
144
+ * syscalls but not the work.
145
+ *
146
+ * Lazily built: a run that never judges a path allocates nothing.
147
+ */
148
+ get spellingIndex(): DirectorySpellingIndex;
63
149
  /**
64
150
  * Probe counters, for tests and `--debug` output.
65
151
  *
@@ -167,14 +253,32 @@ export declare class FsLookupCache {
167
253
  */
168
254
  realpath(targetPath: string): Promise<string>;
169
255
  /**
170
- * Entry names of `dirPath`, or `null` when it cannot be read (missing directory,
171
- * no permission). The unreadable answer is cached too — re-asking is the same
172
- * failed syscall.
256
+ * What `dirPath` holds, or which of the two ways the question went unanswered.
257
+ *
258
+ * A *stable* failure is cached like a success: re-asking a directory whose
259
+ * mode bits refuse us, or whose path is a symlink cycle, is the same failed
260
+ * syscall. A **transient** one is not — see {@link TRANSIENT_LISTING_ERRNOS}.
261
+ *
262
+ * ⚠️ **The transient entry is dropped only once the promise has SETTLED, and
263
+ * that timing is the whole design.** Deleting the row up front, or refusing to
264
+ * store it, would make every concurrent caller start its own `readdir` —
265
+ * turning the descriptor shortage `EMFILE` reports into a descriptor storm,
266
+ * i.e. answering the failure with more of its cause. Storing the in-flight
267
+ * promise keeps the collapse-N-callers-to-one-syscall property intact through
268
+ * the failure; evicting after it settles is what stops the *next* wave from
269
+ * inheriting a verdict about a moment that has passed.
270
+ *
271
+ * The alternative considered and rejected was a bounded retry inside this
272
+ * method. It re-issues the syscall *while the shortage is still in progress*
273
+ * (which is the storm again, only self-inflicted), it needs a backoff timer to
274
+ * be worth anything, and it hides latency inside a call every caller reads as
275
+ * a memo lookup. Letting the next ask pay one syscall is the same cost the
276
+ * cache already bounds: one per directory, per wave.
173
277
  *
174
278
  * @param dirPath - Directory to list
175
- * @returns Entry names, or `null` if the directory could not be read
279
+ * @returns The entry names, or why there are none to hand back
176
280
  */
177
- readdir(dirPath: string): Promise<string[] | null>;
281
+ readdir(dirPath: string): Promise<DirectoryListing>;
178
282
  }
179
283
  /**
180
284
  * Recursively copy a directory
@@ -187,215 +291,320 @@ export declare class FsLookupCache {
187
291
  */
188
292
  export declare function copyDirectory(src: string, dest: string): Promise<void>;
189
293
  /**
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.
294
+ * Which spelling rule matched a name, and therefore how faithfully the
295
+ * asked-for spelling matches disk.
296
+ *
297
+ * The three that are not `absent` are ordered from most to least faithful, and
298
+ * every consumer that reports to a human needs the distinction: only `exact`
299
+ * opens on every filesystem.
300
+ *
301
+ * **The three rules are tried strictly in the order below, first match wins —
302
+ * the order IS the contract**, because each accepts a strictly weaker notion of
303
+ * sameness and a weaker rule reached first would mislabel a file that is
304
+ * genuinely there. {@link DirectorySpellingIndex} implements them as three
305
+ * lookups over one pre-built index (`indexEntry`/`lookupIn`); it is the only
306
+ * judge, so nothing upstream can disagree with it about what "the same
307
+ * filename" means.
308
+ *
309
+ * ⚠️ **`exact` and `normalized` are not the same verdict, and collapsing them
310
+ * is a silently-wrong answer rather than a lost nicety.** Folding both sides
311
+ * *before* comparing repairs the false "missing" on macOS/APFS — `é` has two
312
+ * encodings (NFC `U+00E9` vs NFD `e` + `U+0301`) that are `!==` and that
313
+ * case-folding does not reconcile, so an accented file that plainly exists was
314
+ * once reported flatly *missing* — and over-corrects into the opposite error on
315
+ * Linux/ext4, where the filesystem is byte-exact: a markdown link spelling a
316
+ * filename NFD while disk holds NFC genuinely 404s there, and a folded judge
317
+ * answers "exists, exact match, no issue". Keeping both facts is the point —
318
+ * the link resolves (so it must not be reported broken), *and* it resolves only
319
+ * by folding (so a caller can warn). `@vibe-agent-toolkit/resources` turns
320
+ * `'normalized'` into `LINK_NORMALIZATION_MISMATCH`. This is one of three sites
321
+ * on that seam; the class is collected in
322
+ * `docs/architecture/resource-scanning-and-caching.md` §3.6 (ledger entry D7).
323
+ *
324
+ * ⚠️ **Case-folding is applied to the NFC-folded form, not to raw bytes.**
325
+ * `toLowerCase()` does not reconcile NFC against NFD, so a name that differs in
326
+ * *both* case and normalization would fall out as `absent` and the author would
327
+ * lose the suggestion. The prohibition that bounds every fold — it yields a
328
+ * comparison key, never a path to open — is stated once at {@link toNfc}, which
329
+ * is also where the reason it is not folded into `safePath.resolve` lives.
196
330
  */
197
- export interface SiblingNames {
331
+ export type FilenameMatch =
332
+ /** The asked-for name and a directory entry are the same bytes. Opens anywhere. */
333
+ 'exact'
334
+ /**
335
+ * They are different bytes that are equal after Unicode NFC folding — the same
336
+ * visible filename in two normalization forms. Opens on macOS/APFS and
337
+ * Windows; **does not open on a byte-exact filesystem** (Linux/ext4, i.e. CI
338
+ * and most deploy targets), where the two forms simply name different files.
339
+ */
340
+ | 'normalized'
341
+ /** They differ by letter case (after folding). Opens only on a case-insensitive filesystem. */
342
+ | 'case_mismatch'
343
+ /** Nothing in the listing matches, or the directory could not be read. */
344
+ | 'absent';
345
+ /**
346
+ * Why a name is not in a listing — the two are a different fact about the tree
347
+ * and a different thing to tell a human.
348
+ *
349
+ * ⛔ **Kept off {@link FilenameMatch} deliberately.** That union names the
350
+ * *spelling rules* a name can match under, and "the directory refused to be
351
+ * listed" is not a spelling rule — it is the reason no rule could be tried. It
352
+ * carries no rank in {@link SPELLING_RANK} and no corrected spelling, and
353
+ * folding it in as a fifth verdict would silently un-exhaust every switch over
354
+ * a spelling (the OKF cross-link lane has one) without moving what those
355
+ * switches actually decide.
356
+ */
357
+ export type AbsenceCause =
358
+ /** The directory was listed and holds nothing matching, under any rule. */
359
+ {
360
+ readonly kind: 'no_such_entry';
361
+ }
362
+ /**
363
+ * A directory on the path could not be listed, so the question was never
364
+ * asked. ⚠️ **This is not evidence of absence** — a `--x` directory is
365
+ * traversable, so the target may well open. A caller reporting it as a
366
+ * missing file is asserting something it has not learned.
367
+ *
368
+ * 🔑 **It carries WHICH directory and WHICH errno because the alternative was
369
+ * a remedy nobody can aim.** Collapsing every refusal to the bare word left
370
+ * each consumer able to say only "a directory on that path refused" — useless
371
+ * to a reader staring at a five-segment path, and identical whether the cause
372
+ * was a mode bit they can fix or a descriptor shortage they should just
373
+ * re-run past.
374
+ */
375
+ | {
376
+ readonly kind: 'directory_unreadable';
377
+ /** The errno `readdir` refused with: `EACCES`, `EMFILE`, `ENFILE`, `ELOOP`, … */
378
+ readonly code: string;
198
379
  /**
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.
380
+ * The directory that refused — **absolute**, forward-slashed.
381
+ *
382
+ * 🔒 **Sanitize before quoting it to a human.** An absolute path in a
383
+ * finding is the developer's `$HOME` in every CI log, and both consumers
384
+ * of this field re-express it against a root they own
385
+ * (`issueLocation(dir, projectRoot)` in the link lane,
386
+ * `safePath.relative(bundleRoot, dir)` in the OKF lane) before it reaches
387
+ * a message. It is absolute *here* because those two roots differ and the
388
+ * walk root this was found under is neither of them.
202
389
  */
203
- readonly expectedName: string;
390
+ readonly directory: string;
204
391
  /**
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}.
392
+ * Whether re-running could get a different answer — see
393
+ * {@link TRANSIENT_LISTING_ERRNOS}.
209
394
  *
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.
395
+ * Derived once, here, rather than by each consumer: two lanes write a
396
+ * "re-run before investigating" remedy off this fact, and a second errno
397
+ * list is exactly how those two come to disagree about it.
216
398
  */
217
- readonly names: readonly string[] | null;
218
- }
399
+ readonly transient: boolean;
400
+ };
401
+ /** The refusal half of {@link AbsenceCause}: a directory that would not be listed. */
402
+ export type DirectoryRefusal = Extract<AbsenceCause, {
403
+ kind: 'directory_unreadable';
404
+ }>;
219
405
  /**
220
- * The materialized listing column: parent directory → that directory's entry
221
- * names, or `null` when it could not be read.
406
+ * The refusal a `readdir` that was refused stands for — errno, directory and
407
+ * whether a re-ask could answer differently, derived ONCE beside the errno list.
222
408
  *
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.
409
+ * Shared by the spelling judge (through {@link absenceCauseFor}) and the crawl
410
+ * that defines the population (`file-crawler.ts`), so the two lanes cannot
411
+ * disagree about which refusals are transient.
227
412
  *
228
- * A *missing key* is a third thing again, and never a legal input to judgement:
229
- * see {@link siblingNamesFrom}.
413
+ * @param listing - A `readdir` outcome that was refused
414
+ * @param directory - The directory that was asked about
415
+ * @returns The refusal, with `directory` forward-slashed
230
416
  */
231
- export type SiblingNamesTable = ReadonlyMap<string, readonly string[] | null>;
417
+ export declare function directoryRefusalFor(listing: Extract<DirectoryListing, {
418
+ outcome: 'unreadable';
419
+ }>, directory: string): DirectoryRefusal;
420
+ /** What one directory entry name matched, and how the directory spells it. */
421
+ export type ComponentMatch = {
422
+ match: Exclude<FilenameMatch, 'absent'>;
423
+ actualName: string;
424
+ } | {
425
+ match: 'absent';
426
+ because: AbsenceCause;
427
+ };
232
428
  /**
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.
235
- *
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.
242
- *
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.
429
+ * What judging a whole path said, and the two spellings a message quotes.
249
430
  *
250
- * @param filePaths - File paths whose parent directories should be listed
251
- * @param fsCache - Per-run lookup cache (one instance per validation run)
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.
257
- *
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.
264
- *
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
431
+ * A union rather than one interface with an optional field: {@link AbsenceCause}
432
+ * is required exactly when the verdict is `absent` and unreachable otherwise, so
433
+ * a caller cannot report a path as missing without having read *which* absence
434
+ * it is.
271
435
  */
272
- export declare function siblingNamesFrom(table: SiblingNamesTable, filePath: string): SiblingNames;
436
+ export type PathSpelling = {
437
+ /** The worst spelling defect on the path. */
438
+ match: Exclude<FilenameMatch, 'absent'>;
439
+ /** The path relative to the walk root, spelled as the caller asked for it. */
440
+ askedPath: string;
441
+ /** The same path as disk spells it. */
442
+ actualPath: string;
443
+ } | {
444
+ /** No component matched — see `because` before calling anything missing. */
445
+ match: 'absent';
446
+ /** The path relative to the walk root, spelled as the caller asked for it. */
447
+ askedPath: string;
448
+ /** Empty: nothing matched, so there is no disk spelling to quote. */
449
+ actualPath: string;
450
+ /** Whether the entry is really gone, or the listing was refused. */
451
+ because: AbsenceCause;
452
+ /**
453
+ * What the walk DID establish before it stopped: the components above
454
+ * the one it could not find or could not ask about.
455
+ *
456
+ * 🪤 Carried because dropping it discarded a verdict. `Locked/t.md`
457
+ * against a disk `locked/` that then refuses to list: component 1 was
458
+ * judged and found a case mismatch — a defect that 404s on a
459
+ * case-sensitive filesystem whatever the mode bit below says — and a
460
+ * bare `absent` threw it away, so the report called the spelling
461
+ * "unverified" about a component VAT had verified and found wrong.
462
+ */
463
+ verified: VerifiedPrefix;
464
+ };
273
465
  /**
274
- * Which pass of {@link classifyFilenameCase} produced the answer.
466
+ * The components of a path a walk judged before it stopped, and their verdict.
275
467
  *
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.
468
+ * Both paths are `/`-joined and relative to the walk root, like the
469
+ * {@link PathSpelling} they ride on; both are empty when the FIRST component
470
+ * is the one that could not be judged.
279
471
  */
280
- export type FilenameMatch =
281
- /** The asked-for name and a directory entry are the same bytes. Opens anywhere. */
282
- 'exact'
472
+ export interface VerifiedPrefix {
473
+ /** The worst spelling defect among the judged components. */
474
+ readonly match: Exclude<FilenameMatch, 'absent'>;
475
+ /** The judged components as the caller spelled them. */
476
+ readonly askedPath: string;
477
+ /** The same components as disk spells them. */
478
+ readonly actualPath: string;
479
+ }
283
480
  /**
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.
481
+ * Every directory a run asks about, listed once and indexed once.
482
+ *
483
+ * ⚠️ **It owns the listings and never hands one out.** That is deliberate: the
484
+ * defect it replaced was a per-path scan over a shared raw array, and an
485
+ * implementation that cannot reach the array cannot scan it. The only ways to
486
+ * ask a question are {@link DirectorySpellingIndex.lookup} and
487
+ * {@link DirectorySpellingIndex.judgePath}, both `Map.get` over an index built
488
+ * at most once per directory — {@link DirectorySpellingIndex.directoriesIndexed}
489
+ * and {@link DirectorySpellingIndex.entriesIndexed} are what a test counts to
490
+ * prove the work did not go back to being per-path.
491
+ *
492
+ * **Instance-per-run, like the {@link FsLookupCache} it borrows** — it holds a
493
+ * snapshot of directory contents and must not outlive the run that took it.
288
494
  */
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 {
495
+ export declare class DirectorySpellingIndex {
496
+ #private;
497
+ constructor(fsCache: FsLookupCache);
498
+ /**
499
+ * How many times a listing was turned into an index.
500
+ *
501
+ * Counted at the BUILD, not as `#indexes.size`: the size is the number of
502
+ * distinct directories asked about, which stays put even if every lookup
503
+ * rebuilds — the exact regression this number exists to catch.
504
+ */
505
+ get directoriesIndexed(): number;
506
+ /** How many directory entries were examined, across every index built. */
507
+ get entriesIndexed(): number;
508
+ /** Every directory that has been listed, for never-reached-above-the-root pins. */
509
+ get indexedDirectories(): string[];
296
510
  /**
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.
511
+ * Ask what `directory` really calls `name`.
512
+ *
513
+ * @param directory - Absolute path of the directory to ask about
514
+ * @param name - One path component, spelled as the caller asked for it
515
+ * @returns Which rule matched and the entry's own spelling, or `absent`
301
516
  */
302
- exists: boolean;
517
+ lookup(directory: string, name: string): Promise<ComponentMatch>;
303
518
  /**
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.
519
+ * Judge every component of `resolvedPath`, from `root` down.
520
+ *
521
+ * Each component is judged against the directory that actually holds it —
522
+ * which is the corrected spelling of the previous component, not the
523
+ * asked-for one, so a wrong directory name does not hide a wrong filename
524
+ * beneath it.
525
+ *
526
+ * ⛔ **It never looks above `root`.** The walk starts there and only
527
+ * descends, and a path that does not live under `root` is refused outright
528
+ * rather than walked from somewhere else: a verdict that depends on a
529
+ * directory above the root is a verdict that changes when the tree is moved.
530
+ * Pick a root the caller has already enumerated, and every component below it
531
+ * is one the *reference text* contributed — exactly the ones worth judging.
532
+ *
533
+ * @param root - Absolute path of a directory known to exist, and an ancestor
534
+ * of `resolvedPath` (or `resolvedPath` itself)
535
+ * @param resolvedPath - Absolute path to judge
536
+ * @returns The worst spelling defect on the path, plus both spellings of it
537
+ * @throws If `resolvedPath` does not live at or under `root`
308
538
  */
309
- actualName: string | null;
310
- /** Which pass matched. See {@link FilenameMatch}. */
311
- match: FilenameMatch;
539
+ judgePath(root: string, resolvedPath: string): Promise<PathSpelling>;
312
540
  }
541
+ /** One path to judge, paired with the file whose text asked for it. */
542
+ export interface PathSpellingRequest {
543
+ /** The referring file, whose own path was enumerated and is therefore trusted. */
544
+ referrer: string;
545
+ /** The absolute path the reference resolved to. */
546
+ target: string;
547
+ }
548
+ /**
549
+ * Where to start judging `target`, given that `referrer`'s own path came off
550
+ * the filesystem rather than out of a document.
551
+ *
552
+ * ⚠️ **The root is the deepest directory the two paths share, and that choice
553
+ * is doing real work in both directions.** Everything *above* it was enumerated
554
+ * (so judging it would compare disk against disk, and on a macOS crawl that
555
+ * routinely means reporting an NFD component nobody wrote); everything *below*
556
+ * it is what the reference text contributed, and is precisely what a
557
+ * misspelling can hide in.
558
+ *
559
+ * Falls back to the target's own parent — i.e. judging the basename alone, the
560
+ * weakest useful answer — when the two paths share no meaningful ancestor
561
+ * (different drives on Windows, or a relative path).
562
+ *
563
+ * @param referrer - Path of the file holding the reference
564
+ * @param target - Absolute path the reference resolved to
565
+ * @returns The directory to walk down from
566
+ */
567
+ export declare function spellingWalkRoot(referrer: string, target: string): string;
568
+ /**
569
+ * The materialized spelling column: one judged path per distinct
570
+ * (walk root, target) pair.
571
+ *
572
+ * A *missing key* is never a legal input to judgement: see
573
+ * {@link pathSpellingFrom}.
574
+ */
575
+ export type PathSpellingTable = ReadonlyMap<string, PathSpelling>;
313
576
  /**
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)`. The two columns are
331
- * different kinds of value and routinely disagree about one file: `entry` is
332
- * an *enumerated* path (`readdir` hands back whatever is on disk, commonly
333
- * decomposed) while `expectedName` is a path *derived from markdown link
334
- * text* (composed, as an editor writes it). `é` has two encodings (NFC
335
- * `U+00E9` vs NFD `e` + `U+0301`) that are `!==` and that case-folding does
336
- * not reconcile, so without this pass an accented file that plainly exists
337
- * was reported flatly *missing* — not even a case-mismatch hint, since that
338
- * needs pass 3 to match. This is one of three sites on that seam; the class
339
- * is collected in `docs/architecture/resource-scanning-and-caching.md` §3.6
340
- * (ledger entry D7).
341
- * 3. **case-insensitive, on the folded forms.** Folding first is required, not
342
- * tidy: `toLowerCase()` does not reconcile NFC against NFD, so a name that
343
- * differs in *both* case and normalization falls out as `absent` and the
344
- * author loses the suggestion.
345
- *
346
- * ⚠️ **Passes 1 and 2 are not the same verdict, and collapsing them is a
347
- * silently-wrong answer rather than a lost nicety.** The fix for D7 originally
348
- * folded both sides *before* comparing, which repaired the false "missing" on
349
- * macOS/APFS — and over-corrected into the opposite error on Linux/ext4, where
350
- * the filesystem is byte-exact: a markdown link spelling a filename NFD while
351
- * disk holds NFC genuinely 404s there, and the folded judge answered "exists,
352
- * exact match, no issue". `match` is what keeps both facts: the link resolves
353
- * (so it must not be reported broken), *and* it resolves only by folding (so a
354
- * caller can warn). {@link classifyFilenameCaseFrom}'s consumer in
355
- * `@vibe-agent-toolkit/resources` turns `'normalized'` into
356
- * `LINK_NORMALIZATION_MISMATCH`. The prohibition that bounds every fold reached
357
- * from here — it yields a comparison key, never a path to open — is stated once
358
- * at {@link toNfc}, which is also where the reason it is not folded into
359
- * `safePath.resolve` lives.
360
- *
361
- * **Folding is deferred to the miss path, and that is a real saving.** Pass 1
362
- * calls `toNfc` zero times, so a corpus whose links all resolve byte-exactly —
363
- * every pure-ASCII corpus, i.e. nearly all of them — normalizes nothing at all.
364
- * The older shape folded every entry of every directory in the fill,
365
- * unconditionally.
366
- *
367
- * @param row - The listing row, read out of a filled table by {@link siblingNamesFrom}
368
- * @returns The verdict: whether it resolves, the entry really on disk, and which pass matched
577
+ * Judge every request's whole path — the only place I/O is legal for this fact,
578
+ * and the pass that must run *before* any judging.
579
+ *
580
+ * Distinct (root, target) pairs are walked **concurrently**, and every listing
581
+ * they need goes through the cache's own {@link DirectorySpellingIndex}
582
+ * ({@link FsLookupCache.spellingIndex}), so a directory holding N referenced
583
+ * targets is listed once, not N times, a directory on the path to M of them is
584
+ * listed once, not M times, and a caller that fills once per path still indexes
585
+ * each directory only once for the whole run.
586
+ *
587
+ * @param requests - Targets to judge, each paired with its referring file
588
+ * @param fsCache - Per-run lookup cache (one instance per validation run)
589
+ * @returns The filled table; empty input yields an empty table with no syscalls
369
590
  */
370
- export declare function classifyFilenameCase(row: SiblingNames): FilenameCaseVerdict;
591
+ export declare function fillPathSpellings(requests: Iterable<PathSpellingRequest>, fsCache: FsLookupCache): Promise<PathSpellingTable>;
371
592
  /**
372
- * Judge `filePath` against an already-filled {@link SiblingNamesTable}.
373
- *
374
- * This is the judging half of the two-pass shape: {@link fillSiblingNames} does
375
- * every listing first, then this runs over as many paths as you like with no
376
- * interleaved I/O.
377
- *
378
- * **The signature is not what keeps this free of I/O — a test is.** `fs-utils.ts`
379
- * imports `node:fs` and `node:fs/promises` at module scope, so this function's
380
- * module reaches the filesystem freely; taking no {@link FsLookupCache} and no
381
- * `fs` parameter constrains a future edit not at all, which could call
382
- * `nodeFs.statSync` on the next line and still typecheck. What actually holds the
383
- * property is `packages/utils/test/fs-utils.test.ts` →
384
- * *"judges from a filled table, reaching neither readdir nor the sync stat pair"*:
385
- * it spies `fs.readdir`, `nodeFs.existsSync` and `nodeFs.statSync` on the very
386
- * default objects this module imports, drives a positive control through each so
387
- * a zero cannot mean "the instrument never attached", and asserts the counts do
388
- * not move across judgement. If a future check needs another fact about the parent
389
- * directory, widen the *table* rather than reaching for `fs` here — and expect
390
- * that test, not this signature, to be what stops you.
391
- *
392
- * @param table - Table filled by {@link fillSiblingNames}
393
- * @param filePath - Absolute path to judge
394
- * @returns The verdict — see {@link FilenameCaseVerdict}
395
- * @throws If `table` holds no entry for the path's parent directory — see
396
- * {@link siblingNamesFrom}
593
+ * Read the verdict for one reference out of an already-filled table. Pure.
594
+ *
595
+ * **A miss throws rather than degrading to `absent`.** The fill set is derived
596
+ * from exactly the references the judge will be asked about, so a missing row
597
+ * is a programming error — a path judged that nobody filled. Degrading would
598
+ * report every such reference as *missing*: a wrong answer wearing the shape of
599
+ * a graceful degradation, and one no test of the verdict would catch.
600
+ *
601
+ * @param table - Table filled by {@link fillPathSpellings}
602
+ * @param referrer - The file holding the reference
603
+ * @param target - The absolute path it resolved to
604
+ * @returns How faithfully the whole path is spelled
605
+ * @throws If `table` holds no row for this (referrer, target) pair
397
606
  */
398
- export declare function classifyFilenameCaseFrom(table: SiblingNamesTable, filePath: string): FilenameCaseVerdict;
607
+ export declare function pathSpellingFrom(table: PathSpellingTable, referrer: string, target: string): PathSpelling;
399
608
  /**
400
609
  * The materialized realpath column: path → its canonical path.
401
610
  *
@@ -414,9 +623,9 @@ export type RealpathTable = ReadonlyMap<string, string>;
414
623
  * ⚠️ **Rows are keyed by the input path string exactly as given** — not a
415
624
  * dirname, not a re-resolved form. {@link realpathFrom} looks that same string
416
625
  * up, so any normalization applied here and not there is a silent miss (a loud
417
- * one, in fact: the judge throws). Contrast {@link fillSiblingNames}, which keys
418
- * by `path.dirname` *because* many files share one listing; here the answer is
419
- * per path, so the path is the key.
626
+ * one, in fact: the judge throws). Contrast {@link fillPathSpellings}, which
627
+ * keys by (walk root, target) *because* many references share one walk; here the
628
+ * answer is per path, so the path is the key.
420
629
  *
421
630
  * Distinct paths are canonicalized **concurrently**: the shape this replaces
422
631
  * asked one path at a time at judgement time, which serialised every `realpath`
@@ -440,13 +649,11 @@ export declare function fillRealpaths(paths: Iterable<string>, fsCache: FsLookup
440
649
  * column exists to remove: a regression no test of the verdict could catch,
441
650
  * because the verdict would be identical, only slower.
442
651
  *
443
- * Public, unlike {@link siblingNamesFrom}: a sibling-names row is not yet an
444
- * answer (it still needs {@link classifyFilenameCase}), whereas here the row IS
445
- * the answer — so this lookup is itself the judge for this column, and there is
446
- * nothing left to keep internal.
652
+ * The row IS the answer here — nothing further has to judge it — so this lookup
653
+ * is itself the judge for this column.
447
654
  *
448
655
  * **The signature is not what keeps this free of I/O — a test is.** As with
449
- * {@link classifyFilenameCaseFrom}, this module imports `node:fs` and
656
+ * {@link pathSpellingFrom}, this module imports `node:fs` and
450
657
  * `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
451
658
  * the parameter list prevents nothing. The guard is
452
659
  * `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching