@vibe-agent-toolkit/utils 0.2.0-rc.1 → 0.2.0-rc.10

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 (270) hide show
  1. package/README.md +69 -23
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/compare-code-units.d.ts +28 -0
  5. package/dist/compare-code-units.d.ts.map +1 -0
  6. package/dist/compare-code-units.js +33 -0
  7. package/dist/compare-code-units.js.map +1 -0
  8. package/dist/crawl-timing.d.ts +657 -0
  9. package/dist/crawl-timing.d.ts.map +1 -0
  10. package/dist/crawl-timing.js +765 -0
  11. package/dist/crawl-timing.js.map +1 -0
  12. package/dist/crawl.d.ts +2 -4
  13. package/dist/crawl.d.ts.map +1 -1
  14. package/dist/crawl.js +2 -4
  15. package/dist/crawl.js.map +1 -1
  16. package/dist/dirent-kind.d.ts +83 -0
  17. package/dist/dirent-kind.d.ts.map +1 -0
  18. package/dist/dirent-kind.js +129 -0
  19. package/dist/dirent-kind.js.map +1 -0
  20. package/dist/entrypoint.d.ts +54 -0
  21. package/dist/entrypoint.d.ts.map +1 -0
  22. package/dist/entrypoint.js +71 -0
  23. package/dist/entrypoint.js.map +1 -0
  24. package/dist/env-flag.d.ts +51 -0
  25. package/dist/env-flag.d.ts.map +1 -0
  26. package/dist/env-flag.js +62 -0
  27. package/dist/env-flag.js.map +1 -0
  28. package/dist/errors/errno.d.ts +51 -0
  29. package/dist/errors/errno.d.ts.map +1 -0
  30. package/dist/errors/errno.js +112 -0
  31. package/dist/errors/errno.js.map +1 -0
  32. package/dist/errors/vat-error.d.ts +60 -0
  33. package/dist/errors/vat-error.d.ts.map +1 -0
  34. package/dist/errors/vat-error.js +86 -0
  35. package/dist/errors/vat-error.js.map +1 -0
  36. package/dist/file-crawler.d.ts +60 -0
  37. package/dist/file-crawler.d.ts.map +1 -1
  38. package/dist/file-crawler.js +133 -29
  39. package/dist/file-crawler.js.map +1 -1
  40. package/dist/fs/file-hash.d.ts.map +1 -1
  41. package/dist/fs/file-hash.js +0 -1
  42. package/dist/fs/file-hash.js.map +1 -1
  43. package/dist/fs-utils.d.ts +422 -191
  44. package/dist/fs-utils.d.ts.map +1 -1
  45. package/dist/fs-utils.js +534 -204
  46. package/dist/fs-utils.js.map +1 -1
  47. package/dist/fs.d.ts +5 -2
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +9 -7
  50. package/dist/fs.js.map +1 -1
  51. package/dist/git-run.d.ts +167 -0
  52. package/dist/git-run.d.ts.map +1 -0
  53. package/dist/git-run.js +154 -0
  54. package/dist/git-run.js.map +1 -0
  55. package/dist/git-snapshot.d.ts +199 -0
  56. package/dist/git-snapshot.d.ts.map +1 -0
  57. package/dist/git-snapshot.js +250 -0
  58. package/dist/git-snapshot.js.map +1 -0
  59. package/dist/git-tracker.d.ts +166 -3
  60. package/dist/git-tracker.d.ts.map +1 -1
  61. package/dist/git-tracker.js +250 -22
  62. package/dist/git-tracker.js.map +1 -1
  63. package/dist/git-utils.d.ts +84 -1
  64. package/dist/git-utils.d.ts.map +1 -1
  65. package/dist/git-utils.js +208 -82
  66. package/dist/git-utils.js.map +1 -1
  67. package/dist/git.d.ts +13 -4
  68. package/dist/git.d.ts.map +1 -1
  69. package/dist/git.js +12 -4
  70. package/dist/git.js.map +1 -1
  71. package/dist/gitignore-checker.d.ts.map +1 -1
  72. package/dist/gitignore-checker.js +15 -6
  73. package/dist/gitignore-checker.js.map +1 -1
  74. package/dist/glob/glob-pattern.js +5 -5
  75. package/dist/glob/glob-pattern.js.map +1 -1
  76. package/dist/index.d.ts +32 -20
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +95 -50
  79. package/dist/index.js.map +1 -1
  80. package/dist/listing-refusal.d.ts +112 -0
  81. package/dist/listing-refusal.d.ts.map +1 -0
  82. package/dist/listing-refusal.js +105 -0
  83. package/dist/listing-refusal.js.map +1 -0
  84. package/dist/path-containment.d.ts +64 -0
  85. package/dist/path-containment.d.ts.map +1 -0
  86. package/dist/path-containment.js +111 -0
  87. package/dist/path-containment.js.map +1 -0
  88. package/dist/path-core.d.ts +113 -18
  89. package/dist/path-core.d.ts.map +1 -1
  90. package/dist/path-core.js +139 -23
  91. package/dist/path-core.js.map +1 -1
  92. package/dist/path-utils.d.ts.map +1 -1
  93. package/dist/path-utils.js +42 -47
  94. package/dist/path-utils.js.map +1 -1
  95. package/dist/path.d.ts +1 -1
  96. package/dist/path.d.ts.map +1 -1
  97. package/dist/path.js +1 -1
  98. package/dist/path.js.map +1 -1
  99. package/dist/process.d.ts +7 -0
  100. package/dist/process.d.ts.map +1 -1
  101. package/dist/process.js +7 -0
  102. package/dist/process.js.map +1 -1
  103. package/dist/project-utils.d.ts.map +1 -1
  104. package/dist/project-utils.js +15 -9
  105. package/dist/project-utils.js.map +1 -1
  106. package/dist/project.d.ts +7 -5
  107. package/dist/project.d.ts.map +1 -1
  108. package/dist/project.js +7 -5
  109. package/dist/project.js.map +1 -1
  110. package/dist/safe-exec.d.ts +40 -11
  111. package/dist/safe-exec.d.ts.map +1 -1
  112. package/dist/safe-exec.js +111 -25
  113. package/dist/safe-exec.js.map +1 -1
  114. package/dist/skill-targets.d.ts +2 -2
  115. package/dist/skill-targets.js +2 -2
  116. package/dist/skill-test/auth-resolver.d.ts +10 -2
  117. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  118. package/dist/skill-test/auth-resolver.js +26 -11
  119. package/dist/skill-test/auth-resolver.js.map +1 -1
  120. package/dist/skill-test/index.d.ts +1 -1
  121. package/dist/skill-test/index.d.ts.map +1 -1
  122. package/dist/skill-test/index.js +1 -1
  123. package/dist/skill-test/index.js.map +1 -1
  124. package/dist/skill-test/spawn-claude.d.ts +30 -1
  125. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  126. package/dist/skill-test/spawn-claude.js +71 -5
  127. package/dist/skill-test/spawn-claude.js.map +1 -1
  128. package/dist/skill-test/transcript.d.ts +41 -2
  129. package/dist/skill-test/transcript.d.ts.map +1 -1
  130. package/dist/skill-test/transcript.js +55 -10
  131. package/dist/skill-test/transcript.js.map +1 -1
  132. package/dist/spawn-hardened.js +0 -1
  133. package/dist/spawn-hardened.js.map +1 -1
  134. package/dist/stdio-blocking.js +13 -14
  135. package/dist/stdio-blocking.js.map +1 -1
  136. package/dist/test-helpers.d.ts +135 -100
  137. package/dist/test-helpers.d.ts.map +1 -1
  138. package/dist/test-helpers.js +224 -146
  139. package/dist/test-helpers.js.map +1 -1
  140. package/dist/testing/executables.d.ts +32 -0
  141. package/dist/testing/executables.d.ts.map +1 -0
  142. package/dist/testing/executables.js +70 -0
  143. package/dist/testing/executables.js.map +1 -0
  144. package/dist/testing/hostile-tree.d.ts +79 -0
  145. package/dist/testing/hostile-tree.d.ts.map +1 -0
  146. package/dist/testing/hostile-tree.js +164 -0
  147. package/dist/testing/hostile-tree.js.map +1 -0
  148. package/dist/testing/platform-gates.d.ts +31 -0
  149. package/dist/testing/platform-gates.d.ts.map +1 -0
  150. package/dist/testing/platform-gates.js +31 -0
  151. package/dist/testing/platform-gates.js.map +1 -0
  152. package/dist/testing/temp-dir.d.ts +255 -0
  153. package/dist/testing/temp-dir.d.ts.map +1 -0
  154. package/dist/testing/temp-dir.js +363 -0
  155. package/dist/testing/temp-dir.js.map +1 -0
  156. package/dist/testing.d.ts +94 -3
  157. package/dist/testing.d.ts.map +1 -1
  158. package/dist/testing.js +119 -3
  159. package/dist/testing.js.map +1 -1
  160. package/dist/text-content.d.ts +197 -0
  161. package/dist/text-content.d.ts.map +1 -0
  162. package/dist/text-content.js +354 -0
  163. package/dist/text-content.js.map +1 -0
  164. package/dist/text-file.d.ts +45 -0
  165. package/dist/text-file.d.ts.map +1 -0
  166. package/dist/text-file.js +51 -0
  167. package/dist/text-file.js.map +1 -0
  168. package/dist/text.d.ts +16 -0
  169. package/dist/text.d.ts.map +1 -0
  170. package/dist/text.js +16 -0
  171. package/dist/text.js.map +1 -0
  172. package/dist/timing-dump.d.ts +136 -0
  173. package/dist/timing-dump.d.ts.map +1 -0
  174. package/dist/timing-dump.js +229 -0
  175. package/dist/timing-dump.js.map +1 -0
  176. package/dist/windows-shell.d.ts +0 -1
  177. package/dist/windows-shell.d.ts.map +1 -1
  178. package/dist/windows-shell.js +0 -1
  179. package/dist/windows-shell.js.map +1 -1
  180. package/dist/zod-introspection.js +2 -2
  181. package/dist/zod-introspection.js.map +1 -1
  182. package/eslint/README.md +223 -49
  183. package/eslint/index.cjs +111 -97
  184. package/eslint/index.d.cts +16 -6
  185. package/eslint/rules/callee-name.cjs +23 -0
  186. package/eslint/rules/commands-import-boundary.cjs +148 -0
  187. package/eslint/rules/dead-import.cjs +1 -1
  188. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  189. package/eslint/rules/eslint-rule-factory.cjs +5 -2
  190. package/eslint/rules/exempt-path-matcher.cjs +2 -1
  191. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  192. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  193. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  194. package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
  195. package/eslint/rules/no-blind-catch.cjs +171 -0
  196. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  197. package/eslint/rules/no-decaying-referent.cjs +217 -0
  198. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  199. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  200. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  201. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  202. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  203. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  204. package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
  205. package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
  206. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  207. package/eslint/rules/no-manual-path-normalize.cjs +136 -107
  208. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  209. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  210. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  211. package/eslint/rules/no-path-startswith.cjs +7 -2
  212. package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
  213. package/eslint/rules/no-raw-node-path.cjs +563 -0
  214. package/eslint/rules/no-raw-text-decode.cjs +305 -0
  215. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  216. package/eslint/rules/no-self-package-import.cjs +168 -0
  217. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  218. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  219. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  220. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  221. package/eslint/rules/no-version-literal.cjs +151 -0
  222. package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
  223. package/eslint/rules/require-justified-skip.cjs +8 -2
  224. package/eslint/rules/safe-import.cjs +1 -1
  225. package/package.json +16 -16
  226. package/dist/link-auth/build-headers.d.ts +0 -34
  227. package/dist/link-auth/build-headers.d.ts.map +0 -1
  228. package/dist/link-auth/build-headers.js +0 -58
  229. package/dist/link-auth/build-headers.js.map +0 -1
  230. package/dist/link-auth/expand-macro.d.ts +0 -38
  231. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  232. package/dist/link-auth/expand-macro.js +0 -133
  233. package/dist/link-auth/expand-macro.js.map +0 -1
  234. package/dist/link-auth/macros.yaml +0 -50
  235. package/dist/link-auth/resolve-token.d.ts +0 -83
  236. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  237. package/dist/link-auth/resolve-token.js +0 -101
  238. package/dist/link-auth/resolve-token.js.map +0 -1
  239. package/dist/link-auth/resolve.d.ts +0 -102
  240. package/dist/link-auth/resolve.d.ts.map +0 -1
  241. package/dist/link-auth/resolve.js +0 -66
  242. package/dist/link-auth/resolve.js.map +0 -1
  243. package/dist/link-auth/rewrite.d.ts +0 -52
  244. package/dist/link-auth/rewrite.d.ts.map +0 -1
  245. package/dist/link-auth/rewrite.js +0 -102
  246. package/dist/link-auth/rewrite.js.map +0 -1
  247. package/dist/link-auth/select-provider.d.ts +0 -30
  248. package/dist/link-auth/select-provider.d.ts.map +0 -1
  249. package/dist/link-auth/select-provider.js +0 -55
  250. package/dist/link-auth/select-provider.js.map +0 -1
  251. package/dist/link-auth/template.d.ts +0 -40
  252. package/dist/link-auth/template.d.ts.map +0 -1
  253. package/dist/link-auth/template.js +0 -89
  254. package/dist/link-auth/template.js.map +0 -1
  255. package/dist/link-auth/transforms.d.ts +0 -46
  256. package/dist/link-auth/transforms.d.ts.map +0 -1
  257. package/dist/link-auth/transforms.js +0 -52
  258. package/dist/link-auth/transforms.js.map +0 -1
  259. package/dist/template-entry.d.ts +0 -10
  260. package/dist/template-entry.d.ts.map +0 -1
  261. package/dist/template-entry.js +0 -10
  262. package/dist/template-entry.js.map +0 -1
  263. package/dist/template.d.ts +0 -7
  264. package/dist/template.d.ts.map +0 -1
  265. package/dist/template.js +0 -18
  266. package/dist/template.js.map +0 -1
  267. package/eslint/rules/no-path-join.cjs +0 -14
  268. package/eslint/rules/no-path-relative.cjs +0 -14
  269. package/eslint/rules/no-path-resolve.cjs +0 -14
  270. package/eslint/rules/path-function-rule-factory.cjs +0 -496
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Filesystem utilities
3
3
  */
4
+ import { VatError } from './errors/vat-error.js';
4
5
  /**
5
6
  * What one path looked like the first time this run asked.
6
7
  *
@@ -22,6 +23,77 @@ export interface PathProbe {
22
23
  */
23
24
  readonly isDirectory: boolean | null;
24
25
  }
26
+ /**
27
+ * What one `readdir` answered: the entries, or which of the two ways it failed.
28
+ *
29
+ * ⛔ **The two failures are NOT one answer, and collapsing them is a wrong
30
+ * verdict rather than a lost nicety.** This used to be `string[] | null`, where
31
+ * one `null` meant both *"there is no such directory"* and *"I was refused"*.
32
+ * Only the first is absence. A POSIX `--x` directory (mode `0111`) is
33
+ * *traversable* — every file below it opens exactly as written — while
34
+ * `readdir` returns `EACCES`; a judge that walks a path component by component
35
+ * then declared a link that opens fine to be a missing file, and said so with
36
+ * the confident wrong diagnosis *"File not found"*. The condition to report is
37
+ * that a **directory could not be listed**, which is the caller's to decide, and
38
+ * it cannot decide what this type will not carry.
39
+ *
40
+ * The sibling proof that the distinction is real: `resources/src/okf/discovery.ts`
41
+ * already reports an unlistable subdirectory as its own `OKF_SUBDIRECTORY_UNREADABLE`
42
+ * finding rather than as a missing one.
43
+ */
44
+ export type DirectoryListing =
45
+ /** The directory was read. `names` is exactly what `readdir` handed back. */
46
+ {
47
+ readonly outcome: 'listed';
48
+ readonly names: string[];
49
+ }
50
+ /** There is no such directory (`ENOENT`), or a path component is a file (`ENOTDIR`). */
51
+ | {
52
+ readonly outcome: 'absent';
53
+ }
54
+ /**
55
+ * The directory may well hold the entry asked about; the OS refused the
56
+ * question. `code` is the errno, for a caller that reports the reason.
57
+ */
58
+ | {
59
+ readonly outcome: 'unreadable';
60
+ readonly code: string;
61
+ };
62
+ /**
63
+ * Turn a `readdir` rejection into the failure it actually is.
64
+ *
65
+ * ⚠️ **`isFilesystemAccessError` is deliberately NOT used here, and that is not
66
+ * an oversight.** It answers a different question — *"is this the environment's
67
+ * fault or a bug in our code?"* — and to answer it, it deliberately groups
68
+ * `ENOENT` together with `EACCES`. That grouping IS the conflation this function
69
+ * exists to undo, so reusing the predicate would reinstate the defect while
70
+ * looking like sharing.
71
+ *
72
+ * Anything that is not a recognised *absence* errno reads as unreadable,
73
+ * including an error carrying no errno at all: "I could not ask" is the answer
74
+ * that fabricates no finding, and an unrecognised failure has not established
75
+ * that the directory is missing.
76
+ *
77
+ * @param error - Whatever `fs.readdir` rejected with
78
+ * @returns The listing outcome that error stands for
79
+ */
80
+ export declare function listingFailure(error: unknown): DirectoryListing;
81
+ /**
82
+ * The clause a finding prints about a refusal {@link TRANSIENT_LISTING_ERRNOS}
83
+ * calls transient — owned here, beside the list, so it describes every member.
84
+ *
85
+ * 🪤 Both consumers of `AbsenceCause.transient` used to write their own: "`X`
86
+ * is descriptor exhaustion" — true of `EMFILE`/`ENFILE` and false of `EAGAIN`,
87
+ * which is a retryable shortage of some other resource. Two lanes each carrying
88
+ * the wording for a fact this module was made the single owner of is exactly
89
+ * how the lanes come to disagree with it; the errno list and the sentence about
90
+ * it move together only if they live together.
91
+ *
92
+ * @param code - The errno the listing was refused with
93
+ * @returns A clause naming the errno and what kind of condition it is, with no
94
+ * trailing punctuation so a caller can continue the sentence
95
+ */
96
+ export declare function transientRefusalClause(code: string): string;
25
97
  /** How many probes a {@link FsLookupCache} answered, and how many cost syscalls. */
26
98
  export interface PathProbeStats {
27
99
  /** Probe calls received. */
@@ -51,15 +123,30 @@ export interface PathProbeStats {
51
123
  * @example
52
124
  * ```typescript
53
125
  * 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
126
+ * const requests = links.map((link) => ({ referrer: link.from, target: link.target }));
127
+ * const spellings = await fillPathSpellings(requests, fsCache); // all the I/O, once
128
+ * for (const { referrer, target } of requests) {
129
+ * pathSpellingFrom(spellings, referrer, target); // pure — no syscall
58
130
  * }
59
131
  * ```
60
132
  */
61
133
  export declare class FsLookupCache {
62
134
  #private;
135
+ /**
136
+ * The three-way spelling index over this cache's listings — one per run, for
137
+ * the same reason the listings themselves are.
138
+ *
139
+ * ⚠️ **It hangs off the cache rather than off a fill, and that is what makes
140
+ * the index pay.** A caller that judges its paths in one `fillPathSpellings`
141
+ * would be fine either way; a caller that judges them one at a time — which
142
+ * `validateLink` exists to serve — would otherwise re-index the same listing
143
+ * per path, and the cost would go straight back to
144
+ * O(paths × entries-in-that-directory) with the listing memo hiding the
145
+ * syscalls but not the work.
146
+ *
147
+ * Lazily built: a run that never judges a path allocates nothing.
148
+ */
149
+ get spellingIndex(): DirectorySpellingIndex;
63
150
  /**
64
151
  * Probe counters, for tests and `--debug` output.
65
152
  *
@@ -167,17 +254,49 @@ export declare class FsLookupCache {
167
254
  */
168
255
  realpath(targetPath: string): Promise<string>;
169
256
  /**
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.
257
+ * What `dirPath` holds, or which of the two ways the question went unanswered.
258
+ *
259
+ * A *stable* failure is cached like a success: re-asking a directory whose
260
+ * mode bits refuse us, or whose path is a symlink cycle, is the same failed
261
+ * syscall. A **transient** one is not — see {@link TRANSIENT_LISTING_ERRNOS}.
262
+ *
263
+ * ⚠️ **The transient entry is dropped only once the promise has SETTLED, and
264
+ * that timing is the whole design.** Deleting the row up front, or refusing to
265
+ * store it, would make every concurrent caller start its own `readdir` —
266
+ * turning the descriptor shortage `EMFILE` reports into a descriptor storm,
267
+ * i.e. answering the failure with more of its cause. Storing the in-flight
268
+ * promise keeps the collapse-N-callers-to-one-syscall property intact through
269
+ * the failure; evicting after it settles is what stops the *next* wave from
270
+ * inheriting a verdict about a moment that has passed.
271
+ *
272
+ * The alternative considered and rejected was a bounded retry inside this
273
+ * method. It re-issues the syscall *while the shortage is still in progress*
274
+ * (which is the storm again, only self-inflicted), it needs a backoff timer to
275
+ * be worth anything, and it hides latency inside a call every caller reads as
276
+ * a memo lookup. Letting the next ask pay one syscall is the same cost the
277
+ * cache already bounds: one per directory, per wave.
173
278
  *
174
279
  * @param dirPath - Directory to list
175
- * @returns Entry names, or `null` if the directory could not be read
280
+ * @returns The entry names, or why there are none to hand back
176
281
  */
177
- readdir(dirPath: string): Promise<string[] | null>;
282
+ readdir(dirPath: string): Promise<DirectoryListing>;
283
+ }
284
+ /** Thrown when a link inside the tree being copied points outside it. */
285
+ export declare class CopyLinkEscapesSourceError extends VatError {
286
+ constructor(link: string, src: string);
178
287
  }
179
288
  /**
180
- * Recursively copy a directory
289
+ * Recursively copy a directory, following symlinks — contained to `src`.
290
+ *
291
+ * A link is copied as what it points at (a linked directory as its tree, a
292
+ * linked file as its bytes; a dangling link fails loudly in `stat`). Two
293
+ * refusals bound that: a link whose target is not under `src` throws
294
+ * {@link CopyLinkEscapesSourceError} — `scripts/etc -> /etc` used to copy
295
+ * `/etc` into `dist` — and a link that leads the walk back into a directory
296
+ * it already entered throws `DirectoryWalkRevisitedError` (`scripts/loop -> .`
297
+ * used to create `dest/loop/loop/…` until `ENAMETOOLONG`, writing every file
298
+ * at every level first). Adopter-authored trees reach this through
299
+ * `vat agent build`, so neither shape is exotic.
181
300
  *
182
301
  * @param src - Source directory path
183
302
  * @param dest - Destination directory path
@@ -187,206 +306,320 @@ export declare class FsLookupCache {
187
306
  */
188
307
  export declare function copyDirectory(src: string, dest: string): Promise<void>;
189
308
  /**
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.
309
+ * Which spelling rule matched a name, and therefore how faithfully the
310
+ * asked-for spelling matches disk.
311
+ *
312
+ * The three that are not `absent` are ordered from most to least faithful, and
313
+ * every consumer that reports to a human needs the distinction: only `exact`
314
+ * opens on every filesystem.
315
+ *
316
+ * **The three rules are tried strictly in the order below, first match wins —
317
+ * the order IS the contract**, because each accepts a strictly weaker notion of
318
+ * sameness and a weaker rule reached first would mislabel a file that is
319
+ * genuinely there. {@link DirectorySpellingIndex} implements them as three
320
+ * lookups over one pre-built index (`indexEntry`/`lookupIn`); it is the only
321
+ * judge, so nothing upstream can disagree with it about what "the same
322
+ * filename" means.
323
+ *
324
+ * ⚠️ **`exact` and `normalized` are not the same verdict, and collapsing them
325
+ * is a silently-wrong answer rather than a lost nicety.** Folding both sides
326
+ * *before* comparing repairs the false "missing" on macOS/APFS — `é` has two
327
+ * encodings (NFC `U+00E9` vs NFD `e` + `U+0301`) that are `!==` and that
328
+ * case-folding does not reconcile, so an accented file that plainly exists was
329
+ * once reported flatly *missing* — and over-corrects into the opposite error on
330
+ * Linux/ext4, where the filesystem is byte-exact: a markdown link spelling a
331
+ * filename NFD while disk holds NFC genuinely 404s there, and a folded judge
332
+ * answers "exists, exact match, no issue". Keeping both facts is the point —
333
+ * the link resolves (so it must not be reported broken), *and* it resolves only
334
+ * by folding (so a caller can warn). `@vibe-agent-toolkit/resources` turns
335
+ * `'normalized'` into `LINK_NORMALIZATION_MISMATCH`. This is one of three sites
336
+ * on that seam; the class is collected in
337
+ * `docs/architecture/resource-scanning-and-caching.md` §3.6 (ledger entry D7).
338
+ *
339
+ * ⚠️ **Case-folding is applied to the NFC-folded form, not to raw bytes.**
340
+ * `toLowerCase()` does not reconcile NFC against NFD, so a name that differs in
341
+ * *both* case and normalization would fall out as `absent` and the author would
342
+ * lose the suggestion. The prohibition that bounds every fold — it yields a
343
+ * comparison key, never a path to open — is stated once at {@link toNfc}, which
344
+ * is also where the reason it is not folded into `safePath.resolve` lives.
196
345
  */
197
- export interface SiblingNames {
346
+ export type FilenameMatch =
347
+ /** The asked-for name and a directory entry are the same bytes. Opens anywhere. */
348
+ 'exact'
349
+ /**
350
+ * They are different bytes that are equal after Unicode NFC folding — the same
351
+ * visible filename in two normalization forms. Opens on macOS/APFS and
352
+ * Windows; **does not open on a byte-exact filesystem** (Linux/ext4, i.e. CI
353
+ * and most deploy targets), where the two forms simply name different files.
354
+ */
355
+ | 'normalized'
356
+ /** They differ by letter case (after folding). Opens only on a case-insensitive filesystem. */
357
+ | 'case_mismatch'
358
+ /** Nothing in the listing matches, or the directory could not be read. */
359
+ | 'absent';
360
+ /**
361
+ * Why a name is not in a listing — the two are a different fact about the tree
362
+ * and a different thing to tell a human.
363
+ *
364
+ * ⛔ **Kept off {@link FilenameMatch} deliberately.** That union names the
365
+ * *spelling rules* a name can match under, and "the directory refused to be
366
+ * listed" is not a spelling rule — it is the reason no rule could be tried. It
367
+ * carries no rank in {@link SPELLING_RANK} and no corrected spelling, and
368
+ * folding it in as a fifth verdict would silently un-exhaust every switch over
369
+ * a spelling (the OKF cross-link lane has one) without moving what those
370
+ * switches actually decide.
371
+ */
372
+ export type AbsenceCause =
373
+ /** The directory was listed and holds nothing matching, under any rule. */
374
+ {
375
+ readonly kind: 'no_such_entry';
376
+ }
377
+ /**
378
+ * A directory on the path could not be listed, so the question was never
379
+ * asked. ⚠️ **This is not evidence of absence** — a `--x` directory is
380
+ * traversable, so the target may well open. A caller reporting it as a
381
+ * missing file is asserting something it has not learned.
382
+ *
383
+ * 🔑 **It carries WHICH directory and WHICH errno because the alternative was
384
+ * a remedy nobody can aim.** Collapsing every refusal to the bare word left
385
+ * each consumer able to say only "a directory on that path refused" — useless
386
+ * to a reader staring at a five-segment path, and identical whether the cause
387
+ * was a mode bit they can fix or a descriptor shortage they should just
388
+ * re-run past.
389
+ */
390
+ | {
391
+ readonly kind: 'directory_unreadable';
392
+ /** The errno `readdir` refused with: `EACCES`, `EMFILE`, `ENFILE`, `ELOOP`, … */
393
+ readonly code: string;
198
394
  /**
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.
395
+ * The directory that refused — **absolute**, forward-slashed.
396
+ *
397
+ * 🔒 **Sanitize before quoting it to a human.** An absolute path in a
398
+ * finding is the developer's `$HOME` in every CI log, and both consumers
399
+ * of this field re-express it against a root they own
400
+ * (`issueLocation(dir, projectRoot)` in the link lane,
401
+ * `safePath.relative(bundleRoot, dir)` in the OKF lane) before it reaches
402
+ * a message. It is absolute *here* because those two roots differ and the
403
+ * walk root this was found under is neither of them.
202
404
  */
203
- readonly expectedName: string;
405
+ readonly directory: string;
204
406
  /**
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}.
407
+ * Whether re-running could get a different answer — see
408
+ * {@link TRANSIENT_LISTING_ERRNOS}.
209
409
  *
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.
410
+ * Derived once, here, rather than by each consumer: two lanes write a
411
+ * "re-run before investigating" remedy off this fact, and a second errno
412
+ * list is exactly how those two come to disagree about it.
216
413
  */
217
- readonly names: readonly string[] | null;
218
- }
414
+ readonly transient: boolean;
415
+ };
416
+ /** The refusal half of {@link AbsenceCause}: a directory that would not be listed. */
417
+ export type DirectoryRefusal = Extract<AbsenceCause, {
418
+ kind: 'directory_unreadable';
419
+ }>;
219
420
  /**
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.
421
+ * The refusal a `readdir` that was refused stands for — errno, directory and
422
+ * whether a re-ask could answer differently, derived ONCE beside the errno list.
227
423
  *
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.
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.
424
+ * Shared by the spelling judge (through {@link absenceCauseFor}) and the crawl
425
+ * that defines the population (`file-crawler.ts`), so the two lanes cannot
426
+ * disagree about which refusals are transient.
249
427
  *
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
428
+ * @param listing - A `readdir` outcome that was refused
429
+ * @param directory - The directory that was asked about
430
+ * @returns The refusal, with `directory` forward-slashed
253
431
  */
254
- export declare function fillSiblingNames(filePaths: Iterable<string>, fsCache: FsLookupCache): Promise<SiblingNamesTable>;
432
+ export declare function directoryRefusalFor(listing: Extract<DirectoryListing, {
433
+ outcome: 'unreadable';
434
+ }>, directory: string): DirectoryRefusal;
435
+ /** What one directory entry name matched, and how the directory spells it. */
436
+ export type ComponentMatch = {
437
+ match: Exclude<FilenameMatch, 'absent'>;
438
+ actualName: string;
439
+ } | {
440
+ match: 'absent';
441
+ because: AbsenceCause;
442
+ };
255
443
  /**
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.
444
+ * What judging a whole path said, and the two spellings a message quotes.
264
445
  *
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
446
+ * A union rather than one interface with an optional field: {@link AbsenceCause}
447
+ * is required exactly when the verdict is `absent` and unreachable otherwise, so
448
+ * a caller cannot report a path as missing without having read *which* absence
449
+ * it is.
271
450
  */
272
- export declare function siblingNamesFrom(table: SiblingNamesTable, filePath: string): SiblingNames;
451
+ export type PathSpelling = {
452
+ /** The worst spelling defect on the path. */
453
+ match: Exclude<FilenameMatch, 'absent'>;
454
+ /** The path relative to the walk root, spelled as the caller asked for it. */
455
+ askedPath: string;
456
+ /** The same path as disk spells it. */
457
+ actualPath: string;
458
+ } | {
459
+ /** No component matched — see `because` before calling anything missing. */
460
+ match: 'absent';
461
+ /** The path relative to the walk root, spelled as the caller asked for it. */
462
+ askedPath: string;
463
+ /** Empty: nothing matched, so there is no disk spelling to quote. */
464
+ actualPath: string;
465
+ /** Whether the entry is really gone, or the listing was refused. */
466
+ because: AbsenceCause;
467
+ /**
468
+ * What the walk DID establish before it stopped: the components above
469
+ * the one it could not find or could not ask about.
470
+ *
471
+ * 🪤 Carried because dropping it discarded a verdict. `Locked/t.md`
472
+ * against a disk `locked/` that then refuses to list: component 1 was
473
+ * judged and found a case mismatch — a defect that 404s on a
474
+ * case-sensitive filesystem whatever the mode bit below says — and a
475
+ * bare `absent` threw it away, so the report called the spelling
476
+ * "unverified" about a component VAT had verified and found wrong.
477
+ */
478
+ verified: VerifiedPrefix;
479
+ };
273
480
  /**
274
- * Which pass of {@link classifyFilenameCase} produced the answer.
481
+ * The components of a path a walk judged before it stopped, and their verdict.
275
482
  *
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.
483
+ * Both paths are `/`-joined and relative to the walk root, like the
484
+ * {@link PathSpelling} they ride on; both are empty when the FIRST component
485
+ * is the one that could not be judged.
279
486
  */
280
- export type FilenameMatch =
281
- /** The asked-for name and a directory entry are the same bytes. Opens anywhere. */
282
- 'exact'
487
+ export interface VerifiedPrefix {
488
+ /** The worst spelling defect among the judged components. */
489
+ readonly match: Exclude<FilenameMatch, 'absent'>;
490
+ /** The judged components as the caller spelled them. */
491
+ readonly askedPath: string;
492
+ /** The same components as disk spells them. */
493
+ readonly actualPath: string;
494
+ }
283
495
  /**
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.
496
+ * Every directory a run asks about, listed once and indexed once.
497
+ *
498
+ * ⚠️ **It owns the listings and never hands one out.** That is deliberate: the
499
+ * defect it replaced was a per-path scan over a shared raw array, and an
500
+ * implementation that cannot reach the array cannot scan it. The only ways to
501
+ * ask a question are {@link DirectorySpellingIndex.lookup} and
502
+ * {@link DirectorySpellingIndex.judgePath}, both `Map.get` over an index built
503
+ * at most once per directory — {@link DirectorySpellingIndex.directoriesIndexed}
504
+ * and {@link DirectorySpellingIndex.entriesIndexed} are what a test counts to
505
+ * prove the work did not go back to being per-path.
506
+ *
507
+ * **Instance-per-run, like the {@link FsLookupCache} it borrows** — it holds a
508
+ * snapshot of directory contents and must not outlive the run that took it.
288
509
  */
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 {
510
+ export declare class DirectorySpellingIndex {
511
+ #private;
512
+ constructor(fsCache: FsLookupCache);
513
+ /**
514
+ * How many times a listing was turned into an index.
515
+ *
516
+ * Counted at the BUILD, not as `#indexes.size`: the size is the number of
517
+ * distinct directories asked about, which stays put even if every lookup
518
+ * rebuilds — the exact regression this number exists to catch.
519
+ */
520
+ get directoriesIndexed(): number;
521
+ /** How many directory entries were examined, across every index built. */
522
+ get entriesIndexed(): number;
523
+ /** Every directory that has been listed, for never-reached-above-the-root pins. */
524
+ get indexedDirectories(): string[];
296
525
  /**
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.
526
+ * Ask what `directory` really calls `name`.
527
+ *
528
+ * @param directory - Absolute path of the directory to ask about
529
+ * @param name - One path component, spelled as the caller asked for it
530
+ * @returns Which rule matched and the entry's own spelling, or `absent`
301
531
  */
302
- exists: boolean;
532
+ lookup(directory: string, name: string): Promise<ComponentMatch>;
303
533
  /**
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.
534
+ * Judge every component of `resolvedPath`, from `root` down.
535
+ *
536
+ * Each component is judged against the directory that actually holds it —
537
+ * which is the corrected spelling of the previous component, not the
538
+ * asked-for one, so a wrong directory name does not hide a wrong filename
539
+ * beneath it.
540
+ *
541
+ * ⛔ **It never looks above `root`.** The walk starts there and only
542
+ * descends, and a path that does not live under `root` is refused outright
543
+ * rather than walked from somewhere else: a verdict that depends on a
544
+ * directory above the root is a verdict that changes when the tree is moved.
545
+ * Pick a root the caller has already enumerated, and every component below it
546
+ * is one the *reference text* contributed — exactly the ones worth judging.
547
+ *
548
+ * @param root - Absolute path of a directory known to exist, and an ancestor
549
+ * of `resolvedPath` (or `resolvedPath` itself)
550
+ * @param resolvedPath - Absolute path to judge
551
+ * @returns The worst spelling defect on the path, plus both spellings of it
552
+ * @throws If `resolvedPath` does not live at or under `root`
308
553
  */
309
- actualName: string | null;
310
- /** Which pass matched. See {@link FilenameMatch}. */
311
- match: FilenameMatch;
554
+ judgePath(root: string, resolvedPath: string): Promise<PathSpelling>;
312
555
  }
556
+ /** One path to judge, paired with the file whose text asked for it. */
557
+ export interface PathSpellingRequest {
558
+ /** The referring file, whose own path was enumerated and is therefore trusted. */
559
+ referrer: string;
560
+ /** The absolute path the reference resolved to. */
561
+ target: string;
562
+ }
563
+ /**
564
+ * Where to start judging `target`, given that `referrer`'s own path came off
565
+ * the filesystem rather than out of a document.
566
+ *
567
+ * ⚠️ **The root is the deepest directory the two paths share, and that choice
568
+ * is doing real work in both directions.** Everything *above* it was enumerated
569
+ * (so judging it would compare disk against disk, and on a macOS crawl that
570
+ * routinely means reporting an NFD component nobody wrote); everything *below*
571
+ * it is what the reference text contributed, and is precisely what a
572
+ * misspelling can hide in.
573
+ *
574
+ * Falls back to the target's own parent — i.e. judging the basename alone, the
575
+ * weakest useful answer — when the two paths share no meaningful ancestor
576
+ * (different drives on Windows, or a relative path).
577
+ *
578
+ * @param referrer - Path of the file holding the reference
579
+ * @param target - Absolute path the reference resolved to
580
+ * @returns The directory to walk down from
581
+ */
582
+ export declare function spellingWalkRoot(referrer: string, target: string): string;
583
+ /**
584
+ * The materialized spelling column: one judged path per distinct
585
+ * (walk root, target) pair.
586
+ *
587
+ * A *missing key* is never a legal input to judgement: see
588
+ * {@link pathSpellingFrom}.
589
+ */
590
+ export type PathSpellingTable = ReadonlyMap<string, PathSpelling>;
313
591
  /**
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
592
+ * Judge every request's whole path — the only place I/O is legal for this fact,
593
+ * and the pass that must run *before* any judging.
594
+ *
595
+ * Distinct (root, target) pairs are walked **concurrently**, and every listing
596
+ * they need goes through the cache's own {@link DirectorySpellingIndex}
597
+ * ({@link FsLookupCache.spellingIndex}), so a directory holding N referenced
598
+ * targets is listed once, not N times, a directory on the path to M of them is
599
+ * listed once, not M times, and a caller that fills once per path still indexes
600
+ * each directory only once for the whole run.
601
+ *
602
+ * @param requests - Targets to judge, each paired with its referring file
603
+ * @param fsCache - Per-run lookup cache (one instance per validation run)
604
+ * @returns The filled table; empty input yields an empty table with no syscalls
360
605
  */
361
- export declare function classifyFilenameCase(row: SiblingNames): FilenameCaseVerdict;
606
+ export declare function fillPathSpellings(requests: Iterable<PathSpellingRequest>, fsCache: FsLookupCache): Promise<PathSpellingTable>;
362
607
  /**
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}
608
+ * Read the verdict for one reference out of an already-filled table. Pure.
609
+ *
610
+ * **A miss throws rather than degrading to `absent`.** The fill set is derived
611
+ * from exactly the references the judge will be asked about, so a missing row
612
+ * is a programming error — a path judged that nobody filled. Degrading would
613
+ * report every such reference as *missing*: a wrong answer wearing the shape of
614
+ * a graceful degradation, and one no test of the verdict would catch.
615
+ *
616
+ * @param table - Table filled by {@link fillPathSpellings}
617
+ * @param referrer - The file holding the reference
618
+ * @param target - The absolute path it resolved to
619
+ * @returns How faithfully the whole path is spelled
620
+ * @throws If `table` holds no row for this (referrer, target) pair
388
621
  */
389
- export declare function classifyFilenameCaseFrom(table: SiblingNamesTable, filePath: string): FilenameCaseVerdict;
622
+ export declare function pathSpellingFrom(table: PathSpellingTable, referrer: string, target: string): PathSpelling;
390
623
  /**
391
624
  * The materialized realpath column: path → its canonical path.
392
625
  *
@@ -405,9 +638,9 @@ export type RealpathTable = ReadonlyMap<string, string>;
405
638
  * ⚠️ **Rows are keyed by the input path string exactly as given** — not a
406
639
  * dirname, not a re-resolved form. {@link realpathFrom} looks that same string
407
640
  * 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.
641
+ * one, in fact: the judge throws). Contrast {@link fillPathSpellings}, which
642
+ * keys by (walk root, target) *because* many references share one walk; here the
643
+ * answer is per path, so the path is the key.
411
644
  *
412
645
  * Distinct paths are canonicalized **concurrently**: the shape this replaces
413
646
  * asked one path at a time at judgement time, which serialised every `realpath`
@@ -431,13 +664,11 @@ export declare function fillRealpaths(paths: Iterable<string>, fsCache: FsLookup
431
664
  * column exists to remove: a regression no test of the verdict could catch,
432
665
  * because the verdict would be identical, only slower.
433
666
  *
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.
667
+ * The row IS the answer here — nothing further has to judge it — so this lookup
668
+ * is itself the judge for this column.
438
669
  *
439
670
  * **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
671
+ * {@link pathSpellingFrom}, this module imports `node:fs` and
441
672
  * `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
442
673
  * the parameter list prevents nothing. The guard is
443
674
  * `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching