@vibe-agent-toolkit/utils 0.1.42 → 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 (279) hide show
  1. package/README.md +81 -19
  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 +214 -24
  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 +628 -38
  44. package/dist/fs-utils.d.ts.map +1 -1
  45. package/dist/fs-utils.js +770 -71
  46. package/dist/fs-utils.js.map +1 -1
  47. package/dist/fs.d.ts +5 -1
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +9 -1
  50. package/dist/fs.js.map +1 -1
  51. package/dist/git-root-cache.d.ts +44 -0
  52. package/dist/git-root-cache.d.ts.map +1 -0
  53. package/dist/git-root-cache.js +68 -0
  54. package/dist/git-root-cache.js.map +1 -0
  55. package/dist/git-run.d.ts +167 -0
  56. package/dist/git-run.d.ts.map +1 -0
  57. package/dist/git-run.js +154 -0
  58. package/dist/git-run.js.map +1 -0
  59. package/dist/git-snapshot.d.ts +199 -0
  60. package/dist/git-snapshot.d.ts.map +1 -0
  61. package/dist/git-snapshot.js +250 -0
  62. package/dist/git-snapshot.js.map +1 -0
  63. package/dist/git-tracker.d.ts +166 -3
  64. package/dist/git-tracker.d.ts.map +1 -1
  65. package/dist/git-tracker.js +250 -22
  66. package/dist/git-tracker.js.map +1 -1
  67. package/dist/git-utils.d.ts +95 -1
  68. package/dist/git-utils.d.ts.map +1 -1
  69. package/dist/git-utils.js +227 -81
  70. package/dist/git-utils.js.map +1 -1
  71. package/dist/git.d.ts +13 -4
  72. package/dist/git.d.ts.map +1 -1
  73. package/dist/git.js +12 -4
  74. package/dist/git.js.map +1 -1
  75. package/dist/gitignore-checker.d.ts.map +1 -1
  76. package/dist/gitignore-checker.js +15 -6
  77. package/dist/gitignore-checker.js.map +1 -1
  78. package/dist/glob/glob-pattern.js +5 -5
  79. package/dist/glob/glob-pattern.js.map +1 -1
  80. package/dist/index.d.ts +33 -19
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +113 -33
  83. package/dist/index.js.map +1 -1
  84. package/dist/listing-refusal.d.ts +112 -0
  85. package/dist/listing-refusal.d.ts.map +1 -0
  86. package/dist/listing-refusal.js +105 -0
  87. package/dist/listing-refusal.js.map +1 -0
  88. package/dist/numeric-args.d.ts +24 -0
  89. package/dist/numeric-args.d.ts.map +1 -0
  90. package/dist/numeric-args.js +37 -0
  91. package/dist/numeric-args.js.map +1 -0
  92. package/dist/path-containment.d.ts +64 -0
  93. package/dist/path-containment.d.ts.map +1 -0
  94. package/dist/path-containment.js +111 -0
  95. package/dist/path-containment.js.map +1 -0
  96. package/dist/path-core.d.ts +143 -18
  97. package/dist/path-core.d.ts.map +1 -1
  98. package/dist/path-core.js +171 -23
  99. package/dist/path-core.js.map +1 -1
  100. package/dist/path-utils.d.ts.map +1 -1
  101. package/dist/path-utils.js +42 -47
  102. package/dist/path-utils.js.map +1 -1
  103. package/dist/path.d.ts +1 -1
  104. package/dist/path.d.ts.map +1 -1
  105. package/dist/path.js +1 -1
  106. package/dist/path.js.map +1 -1
  107. package/dist/process.d.ts +7 -0
  108. package/dist/process.d.ts.map +1 -1
  109. package/dist/process.js +7 -0
  110. package/dist/process.js.map +1 -1
  111. package/dist/project-utils.d.ts +7 -1
  112. package/dist/project-utils.d.ts.map +1 -1
  113. package/dist/project-utils.js +24 -10
  114. package/dist/project-utils.js.map +1 -1
  115. package/dist/project.d.ts +7 -5
  116. package/dist/project.d.ts.map +1 -1
  117. package/dist/project.js +7 -5
  118. package/dist/project.js.map +1 -1
  119. package/dist/safe-exec.d.ts +40 -11
  120. package/dist/safe-exec.d.ts.map +1 -1
  121. package/dist/safe-exec.js +111 -25
  122. package/dist/safe-exec.js.map +1 -1
  123. package/dist/skill-targets.d.ts +2 -2
  124. package/dist/skill-targets.js +2 -2
  125. package/dist/skill-test/auth-resolver.d.ts +10 -2
  126. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  127. package/dist/skill-test/auth-resolver.js +26 -11
  128. package/dist/skill-test/auth-resolver.js.map +1 -1
  129. package/dist/skill-test/index.d.ts +1 -1
  130. package/dist/skill-test/index.d.ts.map +1 -1
  131. package/dist/skill-test/index.js +1 -1
  132. package/dist/skill-test/index.js.map +1 -1
  133. package/dist/skill-test/spawn-claude.d.ts +30 -1
  134. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  135. package/dist/skill-test/spawn-claude.js +71 -5
  136. package/dist/skill-test/spawn-claude.js.map +1 -1
  137. package/dist/skill-test/transcript.d.ts +41 -2
  138. package/dist/skill-test/transcript.d.ts.map +1 -1
  139. package/dist/skill-test/transcript.js +55 -10
  140. package/dist/skill-test/transcript.js.map +1 -1
  141. package/dist/spawn-hardened.js +0 -1
  142. package/dist/spawn-hardened.js.map +1 -1
  143. package/dist/stdio-blocking.js +13 -14
  144. package/dist/stdio-blocking.js.map +1 -1
  145. package/dist/test-helpers.d.ts +130 -79
  146. package/dist/test-helpers.d.ts.map +1 -1
  147. package/dist/test-helpers.js +229 -124
  148. package/dist/test-helpers.js.map +1 -1
  149. package/dist/testing/executables.d.ts +32 -0
  150. package/dist/testing/executables.d.ts.map +1 -0
  151. package/dist/testing/executables.js +70 -0
  152. package/dist/testing/executables.js.map +1 -0
  153. package/dist/testing/hostile-tree.d.ts +79 -0
  154. package/dist/testing/hostile-tree.d.ts.map +1 -0
  155. package/dist/testing/hostile-tree.js +164 -0
  156. package/dist/testing/hostile-tree.js.map +1 -0
  157. package/dist/testing/platform-gates.d.ts +31 -0
  158. package/dist/testing/platform-gates.d.ts.map +1 -0
  159. package/dist/testing/platform-gates.js +31 -0
  160. package/dist/testing/platform-gates.js.map +1 -0
  161. package/dist/testing/temp-dir.d.ts +255 -0
  162. package/dist/testing/temp-dir.d.ts.map +1 -0
  163. package/dist/testing/temp-dir.js +363 -0
  164. package/dist/testing/temp-dir.js.map +1 -0
  165. package/dist/testing.d.ts +94 -3
  166. package/dist/testing.d.ts.map +1 -1
  167. package/dist/testing.js +119 -3
  168. package/dist/testing.js.map +1 -1
  169. package/dist/text-content.d.ts +197 -0
  170. package/dist/text-content.d.ts.map +1 -0
  171. package/dist/text-content.js +354 -0
  172. package/dist/text-content.js.map +1 -0
  173. package/dist/text-file.d.ts +45 -0
  174. package/dist/text-file.d.ts.map +1 -0
  175. package/dist/text-file.js +51 -0
  176. package/dist/text-file.js.map +1 -0
  177. package/dist/text.d.ts +16 -0
  178. package/dist/text.d.ts.map +1 -0
  179. package/dist/text.js +16 -0
  180. package/dist/text.js.map +1 -0
  181. package/dist/timing-dump.d.ts +136 -0
  182. package/dist/timing-dump.d.ts.map +1 -0
  183. package/dist/timing-dump.js +229 -0
  184. package/dist/timing-dump.js.map +1 -0
  185. package/dist/windows-shell.d.ts +0 -1
  186. package/dist/windows-shell.d.ts.map +1 -1
  187. package/dist/windows-shell.js +0 -1
  188. package/dist/windows-shell.js.map +1 -1
  189. package/dist/zod-introspection.js +2 -2
  190. package/dist/zod-introspection.js.map +1 -1
  191. package/eslint/README.md +224 -50
  192. package/eslint/index.cjs +111 -97
  193. package/eslint/index.d.cts +16 -6
  194. package/eslint/rules/callee-name.cjs +23 -0
  195. package/eslint/rules/commands-import-boundary.cjs +148 -0
  196. package/eslint/rules/dead-import.cjs +62 -12
  197. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  198. package/eslint/rules/eslint-rule-factory.cjs +21 -3
  199. package/eslint/rules/exempt-path-matcher.cjs +2 -1
  200. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  201. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  202. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  203. package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
  204. package/eslint/rules/no-blind-catch.cjs +171 -0
  205. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  206. package/eslint/rules/no-decaying-referent.cjs +217 -0
  207. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  208. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  209. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  210. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  211. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  212. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  213. package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
  214. package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
  215. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  216. package/eslint/rules/no-manual-path-normalize.cjs +146 -96
  217. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  218. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  219. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  220. package/eslint/rules/no-path-startswith.cjs +7 -2
  221. package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
  222. package/eslint/rules/no-raw-node-path.cjs +563 -0
  223. package/eslint/rules/no-raw-text-decode.cjs +305 -0
  224. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  225. package/eslint/rules/no-self-package-import.cjs +168 -0
  226. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  227. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  228. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  229. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  230. package/eslint/rules/no-version-literal.cjs +151 -0
  231. package/eslint/rules/prefer-startswith-over-regex.cjs +33 -1
  232. package/eslint/rules/require-justified-skip.cjs +8 -2
  233. package/eslint/rules/safe-import.cjs +1 -1
  234. package/package.json +16 -16
  235. package/dist/link-auth/build-headers.d.ts +0 -34
  236. package/dist/link-auth/build-headers.d.ts.map +0 -1
  237. package/dist/link-auth/build-headers.js +0 -58
  238. package/dist/link-auth/build-headers.js.map +0 -1
  239. package/dist/link-auth/expand-macro.d.ts +0 -38
  240. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  241. package/dist/link-auth/expand-macro.js +0 -133
  242. package/dist/link-auth/expand-macro.js.map +0 -1
  243. package/dist/link-auth/macros.yaml +0 -50
  244. package/dist/link-auth/resolve-token.d.ts +0 -83
  245. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  246. package/dist/link-auth/resolve-token.js +0 -101
  247. package/dist/link-auth/resolve-token.js.map +0 -1
  248. package/dist/link-auth/resolve.d.ts +0 -102
  249. package/dist/link-auth/resolve.d.ts.map +0 -1
  250. package/dist/link-auth/resolve.js +0 -66
  251. package/dist/link-auth/resolve.js.map +0 -1
  252. package/dist/link-auth/rewrite.d.ts +0 -52
  253. package/dist/link-auth/rewrite.d.ts.map +0 -1
  254. package/dist/link-auth/rewrite.js +0 -102
  255. package/dist/link-auth/rewrite.js.map +0 -1
  256. package/dist/link-auth/select-provider.d.ts +0 -30
  257. package/dist/link-auth/select-provider.d.ts.map +0 -1
  258. package/dist/link-auth/select-provider.js +0 -55
  259. package/dist/link-auth/select-provider.js.map +0 -1
  260. package/dist/link-auth/template.d.ts +0 -40
  261. package/dist/link-auth/template.d.ts.map +0 -1
  262. package/dist/link-auth/template.js +0 -89
  263. package/dist/link-auth/template.js.map +0 -1
  264. package/dist/link-auth/transforms.d.ts +0 -46
  265. package/dist/link-auth/transforms.d.ts.map +0 -1
  266. package/dist/link-auth/transforms.js +0 -52
  267. package/dist/link-auth/transforms.js.map +0 -1
  268. package/dist/template-entry.d.ts +0 -10
  269. package/dist/template-entry.d.ts.map +0 -1
  270. package/dist/template-entry.js +0 -10
  271. package/dist/template-entry.js.map +0 -1
  272. package/dist/template.d.ts +0 -7
  273. package/dist/template.d.ts.map +0 -1
  274. package/dist/template.js +0 -18
  275. package/dist/template.js.map +0 -1
  276. package/eslint/rules/no-path-join.cjs +0 -14
  277. package/eslint/rules/no-path-relative.cjs +0 -14
  278. package/eslint/rules/no-path-resolve.cjs +0 -14
  279. package/eslint/rules/path-function-rule-factory.cjs +0 -417
@@ -1,6 +1,106 @@
1
1
  /**
2
2
  * Filesystem utilities
3
3
  */
4
+ import { VatError } from './errors/vat-error.js';
5
+ /**
6
+ * What one path looked like the first time this run asked.
7
+ *
8
+ * The two fields are deliberately NOT collapsed into a single `stat` result:
9
+ * they record the outcome of `existsSync` and of `statSync` *separately*,
10
+ * because callers distinguish three states and only two of them are "the stat
11
+ * worked". See {@link FsLookupCache.probe}.
12
+ */
13
+ export interface PathProbe {
14
+ /** `existsSync` — follows symlinks, so a dangling link reads as absent. */
15
+ readonly exists: boolean;
16
+ /**
17
+ * `statSync().isDirectory()`.
18
+ *
19
+ * `null` means *no answer*, which happens two ways: the path is absent, or
20
+ * it exists and `statSync` threw anyway (a permission change or a delete
21
+ * between the two calls). Callers that must tell those apart read
22
+ * {@link PathProbe.exists} alongside it.
23
+ */
24
+ readonly isDirectory: boolean | null;
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;
97
+ /** How many probes a {@link FsLookupCache} answered, and how many cost syscalls. */
98
+ export interface PathProbeStats {
99
+ /** Probe calls received. */
100
+ readonly probes: number;
101
+ /** Probes that were not already memoized, i.e. that hit the filesystem. */
102
+ readonly misses: number;
103
+ }
4
104
  /**
5
105
  * Per-run memo for the two filesystem lookups that validation repeats on values
6
106
  * which are constant for the whole run: `realpath` of roots, and `readdir` of the
@@ -17,37 +117,186 @@
17
117
  * arbitrarily long ago. The intended lifetime is one instance per validation run,
18
118
  * constructed as a local and collected with the run.
19
119
  *
120
+ * Fill first, then judge. The loop holds no `await`, because every listing the
121
+ * loop could have needed was already taken:
122
+ *
20
123
  * @example
21
124
  * ```typescript
22
125
  * const fsCache = new FsLookupCache(); // one per run
23
- * for (const link of links) {
24
- * await verifyCaseSensitiveFilename(link.target, fsCache);
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
25
130
  * }
26
131
  * ```
27
132
  */
28
133
  export declare class FsLookupCache {
29
134
  #private;
30
135
  /**
31
- * Canonical path for `targetPath`, falling back to `safePath.resolve()` when the
32
- * path does not exist or cannot be resolved (a non-existent file has no realpath,
33
- * and callers comparing paths still need an answer).
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;
150
+ /**
151
+ * Probe counters, for tests and `--debug` output.
152
+ *
153
+ * A memo whose tests never assert its hit count is theatre: every assertion
154
+ * about *values* still passes when the memo is disabled, because an
155
+ * always-miss cache returns the same answers — only more slowly. This is the
156
+ * one observable that dies when the memo does.
157
+ */
158
+ get probeStats(): PathProbeStats;
159
+ /**
160
+ * Does this path exist, and is it a directory — asked once per run.
161
+ *
162
+ * **Both syscalls are preserved, in order, exactly as an uncached caller
163
+ * would make them.** `existsSync` then `statSync` is not the same as one
164
+ * `statSync`: the pair distinguishes "absent" from "present but unstattable",
165
+ * and the link walker's classifier branches differently on each. Collapsing
166
+ * them would be a behaviour change wearing the shape of an optimization, so
167
+ * this method deduplicates the pair rather than replacing it.
168
+ *
169
+ * Synchronous, unlike this class's other two lookups, because its caller (the
170
+ * skill link-graph walker) is synchronous throughout. One oracle answering
171
+ * both shapes beats a second class that differs only in colour.
172
+ *
173
+ * @param targetPath - Path to probe
174
+ * @returns The recorded existence/kind pair
175
+ */
176
+ probe(targetPath: string): PathProbe;
177
+ /**
178
+ * Canonical path for `targetPath`. A path that cannot be canonicalized is
179
+ * answered from its **deepest existing ancestor** — that ancestor's realpath
180
+ * with the missing remainder re-appended — because a non-existent file has no
181
+ * realpath and callers comparing paths still need an answer.
182
+ *
183
+ * ⚠️ **The fallback must stay in the same NAMESPACE as the success path, which
184
+ * a lexical `safePath.resolve()` is not.** The only consumer of this column
185
+ * compares one canonical path against another (`isWithinProject` /
186
+ * `isWithinProjectFrom`), so an answer resolved lexically is being compared
187
+ * against an answer resolved through symlinks. Where the root traverses a
188
+ * symlink — macOS `/tmp → /private/tmp`, bind mounts, a worktree under a
189
+ * symlinked path — the two spellings differ and the comparison is nonsense.
190
+ * Measured truth table for `isWithinProject(file, root)` under a `link → real`
191
+ * root, before the walk:
192
+ *
193
+ * ```text
194
+ * existing file, symlinked root : true
195
+ * MISSING file, symlinked root : false ← lexical fallback, wrong namespace
196
+ * MISSING file, plain root : true
197
+ * symlink inside pointing out : false (correct either way)
198
+ * ```
199
+ *
200
+ * The middle row is user-visible: a merely BROKEN root-absolute markdown link
201
+ * was reported as *escaping the project*. The walk fixes it without widening
202
+ * containment, because the ancestor is exactly where an escaping symlink
203
+ * lives — a missing file behind a directory link that points outside still
204
+ * canonicalizes outside.
205
+ *
206
+ * The recursion goes through `this.realpath(parent)`, not a private helper, so
207
+ * ancestors land in the same memo and share in-flight promises. A missing
208
+ * file's parent directory is almost always already cached, so the common case
209
+ * costs no extra syscall. **The fixpoint guard is mandatory**: `path.dirname`
210
+ * is idempotent at a root (`'/'` on posix, `'C:/'` for a drive, `'//server/share/'`
211
+ * for a UNC share), so without it the walk never terminates.
212
+ *
213
+ * Errno is deliberately not inspected. EACCES on an existing file and ELOOP on
214
+ * a symlink cycle land in the same catch as ENOENT, and for all three the
215
+ * ancestor's namespace is a strictly better answer than the lexical one.
216
+ *
217
+ * ⚠️ **`promisify(nodeFs.realpath)` — NOT `fs/promises.realpath`. Node ships two
218
+ * different realpaths and they do not agree.** `fs.realpathSync` and the
219
+ * `fs.realpath` *callback* form run Node's own JS implementation: an
220
+ * lstat/readlink walk that preserves the casing you asked for. `fs/promises.realpath`
221
+ * and `fs.realpath.native` call `uv_fs_realpath` (`realpath(3)` /
222
+ * `GetFinalPathNameByHandleW`), which reports the casing **on disk**. On a
223
+ * case-insensitive filesystem — macOS and Windows — those are different strings,
224
+ * and this column feeds *synchronous* judges that previously called
225
+ * `fs.realpathSync` themselves. A column that does not match `realpathSync` byte
226
+ * for byte flips containment verdicts and emits findings the un-refactored code
227
+ * does not. Measured, Node v24.13.1 / darwin, disk holding `<B>/Sub/Target.TXT`,
228
+ * asked for `<B>/sub/target.txt`:
229
+ *
230
+ * ```text
231
+ * realpathSync : <B>/sub/target.txt ← the contract
232
+ * promisify(fs.realpath) : <B>/sub/target.txt ✅ matches (this call)
233
+ * fs/promises.realpath : <B>/Sub/Target.TXT ❌ on-disk casing
234
+ * fs.realpath.native : <B>/Sub/Target.TXT ❌ on-disk casing
235
+ * ```
236
+ *
237
+ * They also disagree on `''`, where the sync form resolves to the cwd and the
238
+ * native form throws `ENOENT`. **Do not "modernize" this back to `fs/promises`** —
239
+ * it reads tidier and silently changes output. `packages/utils/test/fs-utils.test.ts`
240
+ * → *"answers a mis-cased path exactly as realpathSync does, not as the native
241
+ * resolver does"* pins the equivalence (and skips itself on a case-sensitive
242
+ * filesystem, where the two routes cannot be told apart).
243
+ *
244
+ * `promisify` is applied **per call, on the default object**, not once at module
245
+ * scope: an eagerly captured function bypasses any `vi.spyOn(nodeFs, 'realpath')`
246
+ * installed after import, so this method's I/O would count zero — indistinguishable
247
+ * from performing none. (`fs.realpath` carries no `util.promisify.custom`, so this
248
+ * promisification really does get the JS implementation; it is verified, not
249
+ * assumed — see *"routes canonicalization through the node:fs default object"*.)
250
+ * The wrapper is allocated only on a cache MISS, i.e. once per actual syscall.
34
251
  *
35
252
  * @param targetPath - Path to canonicalize
36
253
  * @returns Canonical path with forward slashes on every platform
37
254
  */
38
255
  realpath(targetPath: string): Promise<string>;
39
256
  /**
40
- * Entry names of `dirPath`, or `null` when it cannot be read (missing directory,
41
- * no permission). The unreadable answer is cached too — re-asking is the same
42
- * 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.
43
278
  *
44
279
  * @param dirPath - Directory to list
45
- * @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
46
281
  */
47
- 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);
48
287
  }
49
288
  /**
50
- * 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.
51
300
  *
52
301
  * @param src - Source directory path
53
302
  * @param dest - Destination directory path
@@ -57,39 +306,380 @@ export declare class FsLookupCache {
57
306
  */
58
307
  export declare function copyDirectory(src: string, dest: string): Promise<void>;
59
308
  /**
60
- * Verify that a file exists with the exact case-sensitive filename.
309
+ * Which spelling rule matched a name, and therefore how faithfully the
310
+ * asked-for spelling matches disk.
61
311
  *
62
- * On case-insensitive filesystems (Windows, macOS), a file might be found even if
63
- * the case doesn't match. This function checks that the actual filename on disk
64
- * matches the requested path exactly (case-sensitive).
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.
65
315
  *
66
- * Answering requires listing the target's parent directory. Callers checking many
67
- * paths (every link in a corpus) hit the same handful of directories over and over,
68
- * so the listing comes from a caller-supplied {@link FsLookupCache}.
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.
69
323
  *
70
- * The cache parameter is **required rather than defaulted on purpose**: a default
71
- * would let an unmigrated call site silently keep the un-memoized behaviour, which
72
- * is a no-op wearing the shape of a fix. `new FsLookupCache()` per call reproduces
73
- * the old behaviour exactly, so migrating is mechanical — but it has to be a
74
- * decision someone made.
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).
75
338
  *
76
- * @param filePath - Absolute path to the file to verify
77
- * @param fsCache - Per-run lookup cache (one instance per validation run)
78
- * @returns Object with exists flag and actual filename (or null if not found)
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.
345
+ */
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.
79
363
  *
80
- * @example
81
- * ```typescript
82
- * // On case-insensitive filesystem with file "README.md"
83
- * const fsCache = new FsLookupCache();
84
- * const result1 = await verifyCaseSensitiveFilename('/project/README.md', fsCache);
85
- * // { exists: true, actualName: 'README.md' }
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.
86
382
  *
87
- * const result2 = await verifyCaseSensitiveFilename('/project/readme.md', fsCache);
88
- * // { exists: false, actualName: 'README.md' } - case mismatch!
89
- * ```
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.
90
389
  */
91
- export declare function verifyCaseSensitiveFilename(filePath: string, fsCache: FsLookupCache): Promise<{
92
- exists: boolean;
93
- actualName: string | null;
390
+ | {
391
+ readonly kind: 'directory_unreadable';
392
+ /** The errno `readdir` refused with: `EACCES`, `EMFILE`, `ENFILE`, `ELOOP`, … */
393
+ readonly code: string;
394
+ /**
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.
404
+ */
405
+ readonly directory: string;
406
+ /**
407
+ * Whether re-running could get a different answer — see
408
+ * {@link TRANSIENT_LISTING_ERRNOS}.
409
+ *
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.
413
+ */
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';
94
419
  }>;
420
+ /**
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.
423
+ *
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.
427
+ *
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
431
+ */
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
+ };
443
+ /**
444
+ * What judging a whole path said, and the two spellings a message quotes.
445
+ *
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.
450
+ */
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
+ };
480
+ /**
481
+ * The components of a path a walk judged before it stopped, and their verdict.
482
+ *
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.
486
+ */
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
+ }
495
+ /**
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.
509
+ */
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[];
525
+ /**
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`
531
+ */
532
+ lookup(directory: string, name: string): Promise<ComponentMatch>;
533
+ /**
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`
553
+ */
554
+ judgePath(root: string, resolvedPath: string): Promise<PathSpelling>;
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>;
591
+ /**
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
605
+ */
606
+ export declare function fillPathSpellings(requests: Iterable<PathSpellingRequest>, fsCache: FsLookupCache): Promise<PathSpellingTable>;
607
+ /**
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
621
+ */
622
+ export declare function pathSpellingFrom(table: PathSpellingTable, referrer: string, target: string): PathSpelling;
623
+ /**
624
+ * The materialized realpath column: path → its canonical path.
625
+ *
626
+ * Every filled row is a string — never `null`, never `undefined`.
627
+ * {@link FsLookupCache.realpath} answers a path it cannot canonicalize from that
628
+ * path's deepest existing ancestor rather than failing, because a path that does
629
+ * not exist has no realpath and a caller comparing paths still needs an answer.
630
+ * That fallback IS the contract, and it is what lets `undefined` out of this map
631
+ * mean exactly one thing: *absent key*. See {@link realpathFrom}.
632
+ */
633
+ export type RealpathTable = ReadonlyMap<string, string>;
634
+ /**
635
+ * Canonicalize every path in `paths` — the only place I/O is legal for this
636
+ * fact, and the pass that must run *before* any judging.
637
+ *
638
+ * ⚠️ **Rows are keyed by the input path string exactly as given** — not a
639
+ * dirname, not a re-resolved form. {@link realpathFrom} looks that same string
640
+ * up, so any normalization applied here and not there is a silent miss (a loud
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.
644
+ *
645
+ * Distinct paths are canonicalized **concurrently**: the shape this replaces
646
+ * asked one path at a time at judgement time, which serialised every `realpath`
647
+ * behind the previous path's `await`. De-duplication is by path, so the same
648
+ * path passed N times costs one syscall; the call itself goes through
649
+ * {@link FsLookupCache.realpath}, which memoizes and shares in-flight promises
650
+ * across fills.
651
+ *
652
+ * @param paths - Paths to canonicalize
653
+ * @param fsCache - Per-run lookup cache (one instance per validation run)
654
+ * @returns The filled table; empty input yields an empty table with no syscalls
655
+ */
656
+ export declare function fillRealpaths(paths: Iterable<string>, fsCache: FsLookupCache): Promise<RealpathTable>;
657
+ /**
658
+ * Read the canonical path for `filePath` out of an already-filled table. Pure.
659
+ *
660
+ * **A miss throws rather than degrading to a recomputed realpath.** The fill set
661
+ * is derived from exactly the paths the judge will be asked about, so a missing
662
+ * key is a programming error — a path judged that nobody filled. Recomputing it
663
+ * would answer *correctly* and silently reintroduce the per-path syscall this
664
+ * column exists to remove: a regression no test of the verdict could catch,
665
+ * because the verdict would be identical, only slower.
666
+ *
667
+ * The row IS the answer here — nothing further has to judge it — so this lookup
668
+ * is itself the judge for this column.
669
+ *
670
+ * **The signature is not what keeps this free of I/O — a test is.** As with
671
+ * {@link pathSpellingFrom}, this module imports `node:fs` and
672
+ * `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
673
+ * the parameter list prevents nothing. The guard is
674
+ * `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching
675
+ * neither the async nor the sync realpath"*, which spies `nodeFs.realpath` and
676
+ * `nodeFs.realpathSync` on the module default objects, proves both instruments
677
+ * attached with a positive control, and asserts zero calls across judgement.
678
+ *
679
+ * @param table - Table filled by {@link fillRealpaths}
680
+ * @param filePath - Path being asked about, as it was handed to the fill
681
+ * @returns The canonical path, with forward slashes on every platform
682
+ * @throws If `table` holds no row for `filePath`
683
+ */
684
+ export declare function realpathFrom(table: RealpathTable, filePath: string): string;
95
685
  //# sourceMappingURL=fs-utils.d.ts.map