@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
package/dist/fs-utils.js CHANGED
@@ -1,10 +1,97 @@
1
1
  /**
2
2
  * Filesystem utilities
3
3
  */
4
+ // ⚠️ The sync half is reached through the DEFAULT object (`nodeFs.existsSync`),
5
+ // not as named imports, and that is load-bearing rather than stylistic. Node
6
+ // snapshots a builtin's named ESM exports at import time, so `vi.spyOn(fs,
7
+ // 'existsSync')` cannot see a call made through a named binding — the spy
8
+ // attaches and counts zero, which reads exactly like "this function performs no
9
+ // I/O". `pathSpellingFrom` and `realpathFrom` are guarded by precisely that
10
+ // assertion (they must reach neither `readdir` nor this pair), so a "tidy-up"
11
+ // back to named imports would silently disarm the guard. The async half below
12
+ // already uses the default object for the same reason.
13
+ import nodeFs from 'node:fs';
4
14
  import fs from 'node:fs/promises';
5
15
  import path from 'node:path';
6
- import { toForwardSlash } from './path-core.js';
16
+ import { promisify } from 'node:util';
17
+ import { FollowedWalk } from './dirent-kind.js';
18
+ import { isFilesystemAccessError } from './errors/errno.js';
19
+ import { VatError } from './errors/vat-error.js';
20
+ import { isUnderRoot } from './path-containment.js';
21
+ import { toForwardSlash, toNfc } from './path-core.js';
7
22
  import { safePath } from './path-utils.js';
23
+ /**
24
+ * Turn a `readdir` rejection into the failure it actually is.
25
+ *
26
+ * ⚠️ **`isFilesystemAccessError` is deliberately NOT used here, and that is not
27
+ * an oversight.** It answers a different question — *"is this the environment's
28
+ * fault or a bug in our code?"* — and to answer it, it deliberately groups
29
+ * `ENOENT` together with `EACCES`. That grouping IS the conflation this function
30
+ * exists to undo, so reusing the predicate would reinstate the defect while
31
+ * looking like sharing.
32
+ *
33
+ * Anything that is not a recognised *absence* errno reads as unreadable,
34
+ * including an error carrying no errno at all: "I could not ask" is the answer
35
+ * that fabricates no finding, and an unrecognised failure has not established
36
+ * that the directory is missing.
37
+ *
38
+ * @param error - Whatever `fs.readdir` rejected with
39
+ * @returns The listing outcome that error stands for
40
+ */
41
+ export function listingFailure(error) {
42
+ const code = typeof error === 'object' && error !== null && 'code' in error
43
+ ? error.code
44
+ : undefined;
45
+ // `ENOTDIR` is absence too: a path component that is a file is a directory
46
+ // that does not exist, which is exactly what the caller has to report.
47
+ if (code === 'ENOENT' || code === 'ENOTDIR')
48
+ return { outcome: 'absent' };
49
+ return { outcome: 'unreadable', code: typeof code === 'string' ? code : 'UNKNOWN' };
50
+ }
51
+ /**
52
+ * Refusal errnos that a *re-ask* can legitimately answer differently.
53
+ *
54
+ * ⚠️ **This set decides what may be MEMOIZED, which makes it a correctness
55
+ * boundary rather than a taxonomy.** `EACCES` (a mode bit) and `ELOOP` (a
56
+ * committed symlink cycle) are facts about the tree: they hold for the whole
57
+ * run, re-asking buys the same refusal, and caching them is exactly what
58
+ * {@link FsLookupCache} is for. Descriptor exhaustion is not a fact about the
59
+ * tree at all — it is a fact about this process at one instant — and a memo
60
+ * that keeps one un-verifies every path under that directory for the rest of
61
+ * the run, producing a burst of findings that a re-run does not reproduce.
62
+ *
63
+ * **Deliberately short, and everything unlisted is treated as stable.** The two
64
+ * mistakes are not symmetric: memoizing a transient refusal costs a burst of
65
+ * wrong answers *within one run*, while re-asking a stable one costs an
66
+ * unbounded number of syscalls on a `--x` directory that will refuse every one
67
+ * of them — and on a dead network mount, each of those blocks. `EAGAIN` is
68
+ * included because it is literally "try again"; `ETIMEDOUT`/`ESTALE`/`EBUSY`
69
+ * are not, because a re-ask against failing hardware or a hung mount is the
70
+ * storm this set exists to avoid.
71
+ */
72
+ const TRANSIENT_LISTING_ERRNOS = new Set(['EMFILE', 'ENFILE', 'EAGAIN']);
73
+ /**
74
+ * The clause a finding prints about a refusal {@link TRANSIENT_LISTING_ERRNOS}
75
+ * calls transient — owned here, beside the list, so it describes every member.
76
+ *
77
+ * 🪤 Both consumers of `AbsenceCause.transient` used to write their own: "`X`
78
+ * is descriptor exhaustion" — true of `EMFILE`/`ENFILE` and false of `EAGAIN`,
79
+ * which is a retryable shortage of some other resource. Two lanes each carrying
80
+ * the wording for a fact this module was made the single owner of is exactly
81
+ * how the lanes come to disagree with it; the errno list and the sentence about
82
+ * it move together only if they live together.
83
+ *
84
+ * @param code - The errno the listing was refused with
85
+ * @returns A clause naming the errno and what kind of condition it is, with no
86
+ * trailing punctuation so a caller can continue the sentence
87
+ */
88
+ export function transientRefusalClause(code) {
89
+ return `${code} is a transient shortage (a descriptor or other resource this process ran out of for a moment), not a permission`;
90
+ }
91
+ /** Whether this listing failed in a way a later ask could get past. */
92
+ function isTransientRefusal(listing) {
93
+ return listing.outcome === 'unreadable' && TRANSIENT_LISTING_ERRNOS.has(listing.code);
94
+ }
8
95
  /**
9
96
  * Per-run memo for the two filesystem lookups that validation repeats on values
10
97
  * which are constant for the whole run: `realpath` of roots, and `readdir` of the
@@ -21,23 +108,181 @@ import { safePath } from './path-utils.js';
21
108
  * arbitrarily long ago. The intended lifetime is one instance per validation run,
22
109
  * constructed as a local and collected with the run.
23
110
  *
111
+ * Fill first, then judge. The loop holds no `await`, because every listing the
112
+ * loop could have needed was already taken:
113
+ *
24
114
  * @example
25
115
  * ```typescript
26
116
  * const fsCache = new FsLookupCache(); // one per run
27
- * for (const link of links) {
28
- * await verifyCaseSensitiveFilename(link.target, fsCache);
117
+ * const requests = links.map((link) => ({ referrer: link.from, target: link.target }));
118
+ * const spellings = await fillPathSpellings(requests, fsCache); // all the I/O, once
119
+ * for (const { referrer, target } of requests) {
120
+ * pathSpellingFrom(spellings, referrer, target); // pure — no syscall
29
121
  * }
30
122
  * ```
31
123
  */
32
124
  export class FsLookupCache {
33
- /** Directory path → its entry names, or `null` when the directory is unreadable. */
125
+ /** Directory path → its entry names, or why the listing has none. */
34
126
  #listings = new Map();
35
127
  /** Path → its canonical path, falling back to the resolved path. */
36
128
  #realpaths = new Map();
129
+ /** Path → the existence/kind pair recorded the first time it was probed. */
130
+ #probes = new Map();
131
+ /** Probe calls received, and how many of them reached the filesystem. */
132
+ #probeCount = 0;
133
+ #probeMisses = 0;
134
+ /** The listings turned into spelling indexes, built on first use. */
135
+ #spellingIndex;
136
+ /**
137
+ * The three-way spelling index over this cache's listings — one per run, for
138
+ * the same reason the listings themselves are.
139
+ *
140
+ * ⚠️ **It hangs off the cache rather than off a fill, and that is what makes
141
+ * the index pay.** A caller that judges its paths in one `fillPathSpellings`
142
+ * would be fine either way; a caller that judges them one at a time — which
143
+ * `validateLink` exists to serve — would otherwise re-index the same listing
144
+ * per path, and the cost would go straight back to
145
+ * O(paths × entries-in-that-directory) with the listing memo hiding the
146
+ * syscalls but not the work.
147
+ *
148
+ * Lazily built: a run that never judges a path allocates nothing.
149
+ */
150
+ get spellingIndex() {
151
+ this.#spellingIndex ??= new DirectorySpellingIndex(this);
152
+ return this.#spellingIndex;
153
+ }
154
+ /**
155
+ * Probe counters, for tests and `--debug` output.
156
+ *
157
+ * A memo whose tests never assert its hit count is theatre: every assertion
158
+ * about *values* still passes when the memo is disabled, because an
159
+ * always-miss cache returns the same answers — only more slowly. This is the
160
+ * one observable that dies when the memo does.
161
+ */
162
+ get probeStats() {
163
+ return { probes: this.#probeCount, misses: this.#probeMisses };
164
+ }
37
165
  /**
38
- * Canonical path for `targetPath`, falling back to `safePath.resolve()` when the
39
- * path does not exist or cannot be resolved (a non-existent file has no realpath,
40
- * and callers comparing paths still need an answer).
166
+ * Does this path exist, and is it a directory — asked once per run.
167
+ *
168
+ * **Both syscalls are preserved, in order, exactly as an uncached caller
169
+ * would make them.** `existsSync` then `statSync` is not the same as one
170
+ * `statSync`: the pair distinguishes "absent" from "present but unstattable",
171
+ * and the link walker's classifier branches differently on each. Collapsing
172
+ * them would be a behaviour change wearing the shape of an optimization, so
173
+ * this method deduplicates the pair rather than replacing it.
174
+ *
175
+ * Synchronous, unlike this class's other two lookups, because its caller (the
176
+ * skill link-graph walker) is synchronous throughout. One oracle answering
177
+ * both shapes beats a second class that differs only in colour.
178
+ *
179
+ * @param targetPath - Path to probe
180
+ * @returns The recorded existence/kind pair
181
+ */
182
+ probe(targetPath) {
183
+ this.#probeCount++;
184
+ const cached = this.#probes.get(targetPath);
185
+ if (cached !== undefined)
186
+ return cached;
187
+ this.#probeMisses++;
188
+ const exists = nodeFs.existsSync(targetPath);
189
+ let isDirectory = null;
190
+ if (exists) {
191
+ try {
192
+ isDirectory = nodeFs.statSync(targetPath).isDirectory();
193
+ }
194
+ catch (error) {
195
+ // Present to `existsSync` but unstattable — a permission change or a
196
+ // delete racing between the two calls. `null` records "no answer" rather
197
+ // than guessing `false`, which would read as "it is a file", and the
198
+ // link walker reports it as an unreadable target — so the refusal is
199
+ // SEEN, not swallowed. A bug from under the stat is not a refusal and
200
+ // stays loud; `isFilesystemAccessError` is the right predicate here
201
+ // precisely because it groups every environmental errno together and
202
+ // excludes a `TypeError`.
203
+ if (!isFilesystemAccessError(error))
204
+ throw error;
205
+ isDirectory = null;
206
+ }
207
+ }
208
+ const result = { exists, isDirectory };
209
+ this.#probes.set(targetPath, result);
210
+ return result;
211
+ }
212
+ /**
213
+ * Canonical path for `targetPath`. A path that cannot be canonicalized is
214
+ * answered from its **deepest existing ancestor** — that ancestor's realpath
215
+ * with the missing remainder re-appended — because a non-existent file has no
216
+ * realpath and callers comparing paths still need an answer.
217
+ *
218
+ * ⚠️ **The fallback must stay in the same NAMESPACE as the success path, which
219
+ * a lexical `safePath.resolve()` is not.** The only consumer of this column
220
+ * compares one canonical path against another (`isWithinProject` /
221
+ * `isWithinProjectFrom`), so an answer resolved lexically is being compared
222
+ * against an answer resolved through symlinks. Where the root traverses a
223
+ * symlink — macOS `/tmp → /private/tmp`, bind mounts, a worktree under a
224
+ * symlinked path — the two spellings differ and the comparison is nonsense.
225
+ * Measured truth table for `isWithinProject(file, root)` under a `link → real`
226
+ * root, before the walk:
227
+ *
228
+ * ```text
229
+ * existing file, symlinked root : true
230
+ * MISSING file, symlinked root : false ← lexical fallback, wrong namespace
231
+ * MISSING file, plain root : true
232
+ * symlink inside pointing out : false (correct either way)
233
+ * ```
234
+ *
235
+ * The middle row is user-visible: a merely BROKEN root-absolute markdown link
236
+ * was reported as *escaping the project*. The walk fixes it without widening
237
+ * containment, because the ancestor is exactly where an escaping symlink
238
+ * lives — a missing file behind a directory link that points outside still
239
+ * canonicalizes outside.
240
+ *
241
+ * The recursion goes through `this.realpath(parent)`, not a private helper, so
242
+ * ancestors land in the same memo and share in-flight promises. A missing
243
+ * file's parent directory is almost always already cached, so the common case
244
+ * costs no extra syscall. **The fixpoint guard is mandatory**: `path.dirname`
245
+ * is idempotent at a root (`'/'` on posix, `'C:/'` for a drive, `'//server/share/'`
246
+ * for a UNC share), so without it the walk never terminates.
247
+ *
248
+ * Errno is deliberately not inspected. EACCES on an existing file and ELOOP on
249
+ * a symlink cycle land in the same catch as ENOENT, and for all three the
250
+ * ancestor's namespace is a strictly better answer than the lexical one.
251
+ *
252
+ * ⚠️ **`promisify(nodeFs.realpath)` — NOT `fs/promises.realpath`. Node ships two
253
+ * different realpaths and they do not agree.** `fs.realpathSync` and the
254
+ * `fs.realpath` *callback* form run Node's own JS implementation: an
255
+ * lstat/readlink walk that preserves the casing you asked for. `fs/promises.realpath`
256
+ * and `fs.realpath.native` call `uv_fs_realpath` (`realpath(3)` /
257
+ * `GetFinalPathNameByHandleW`), which reports the casing **on disk**. On a
258
+ * case-insensitive filesystem — macOS and Windows — those are different strings,
259
+ * and this column feeds *synchronous* judges that previously called
260
+ * `fs.realpathSync` themselves. A column that does not match `realpathSync` byte
261
+ * for byte flips containment verdicts and emits findings the un-refactored code
262
+ * does not. Measured, Node v24.13.1 / darwin, disk holding `<B>/Sub/Target.TXT`,
263
+ * asked for `<B>/sub/target.txt`:
264
+ *
265
+ * ```text
266
+ * realpathSync : <B>/sub/target.txt ← the contract
267
+ * promisify(fs.realpath) : <B>/sub/target.txt ✅ matches (this call)
268
+ * fs/promises.realpath : <B>/Sub/Target.TXT ❌ on-disk casing
269
+ * fs.realpath.native : <B>/Sub/Target.TXT ❌ on-disk casing
270
+ * ```
271
+ *
272
+ * They also disagree on `''`, where the sync form resolves to the cwd and the
273
+ * native form throws `ENOENT`. **Do not "modernize" this back to `fs/promises`** —
274
+ * it reads tidier and silently changes output. `packages/utils/test/fs-utils.test.ts`
275
+ * → *"answers a mis-cased path exactly as realpathSync does, not as the native
276
+ * resolver does"* pins the equivalence (and skips itself on a case-sensitive
277
+ * filesystem, where the two routes cannot be told apart).
278
+ *
279
+ * `promisify` is applied **per call, on the default object**, not once at module
280
+ * scope: an eagerly captured function bypasses any `vi.spyOn(nodeFs, 'realpath')`
281
+ * installed after import, so this method's I/O would count zero — indistinguishable
282
+ * from performing none. (`fs.realpath` carries no `util.promisify.custom`, so this
283
+ * promisification really does get the JS implementation; it is verified, not
284
+ * assumed — see *"routes canonicalization through the node:fs default object"*.)
285
+ * The wrapper is allocated only on a cache MISS, i.e. once per actual syscall.
41
286
  *
42
287
  * @param targetPath - Path to canonicalize
43
288
  * @returns Canonical path with forward slashes on every platform
@@ -48,34 +293,118 @@ export class FsLookupCache {
48
293
  return cached;
49
294
  // Stored before the first `await` anywhere can run, so concurrent callers
50
295
  // reaching this method share the one in-flight promise.
51
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
52
- const pending = fs
53
- .realpath(targetPath)
296
+ //
297
+ // No `security/detect-non-literal-fs-filename` suppression here, unlike the
298
+ // sibling lookups: the rule matches a member call on an fs object, and the
299
+ // path is passed to the promisified wrapper instead. The path is
300
+ // caller-validated all the same.
301
+ const pending = promisify(nodeFs.realpath)(targetPath)
54
302
  .then(toForwardSlash)
55
- .catch(() => safePath.resolve(targetPath));
303
+ .catch(() => this.#canonicalizeViaAncestor(targetPath));
56
304
  this.#realpaths.set(targetPath, pending);
57
305
  return pending;
58
306
  }
59
307
  /**
60
- * Entry names of `dirPath`, or `null` when it cannot be read (missing directory,
61
- * no permission). The unreadable answer is cached too — re-asking is the same
62
- * failed syscall.
308
+ * The ancestor walk behind {@link FsLookupCache.realpath}'s fallback: canonicalize
309
+ * the parent — through the public method, so the memo and in-flight sharing
310
+ * apply — and re-append this path's own basename.
311
+ *
312
+ * Runs inside the already-stored promise's `.catch()`, which is what keeps the
313
+ * store-before-await property intact: the row for `targetPath` is in the map
314
+ * before any of this can start.
315
+ *
316
+ * @param targetPath - Path that could not be canonicalized
317
+ * @returns Canonical ancestor plus the missing remainder, forward-slashed
318
+ */
319
+ async #canonicalizeViaAncestor(targetPath) {
320
+ const absolutePath = safePath.resolve(targetPath);
321
+ const parent = toForwardSlash(path.dirname(absolutePath));
322
+ // Fixpoint at a filesystem root — `/`, `C:/`, `//server/share/` — where
323
+ // `dirname` returns its own input. Nothing left to walk, and no guard means
324
+ // no termination.
325
+ if (parent === absolutePath)
326
+ return absolutePath;
327
+ return safePath.join(await this.realpath(parent), path.basename(absolutePath));
328
+ }
329
+ /**
330
+ * What `dirPath` holds, or which of the two ways the question went unanswered.
331
+ *
332
+ * A *stable* failure is cached like a success: re-asking a directory whose
333
+ * mode bits refuse us, or whose path is a symlink cycle, is the same failed
334
+ * syscall. A **transient** one is not — see {@link TRANSIENT_LISTING_ERRNOS}.
335
+ *
336
+ * ⚠️ **The transient entry is dropped only once the promise has SETTLED, and
337
+ * that timing is the whole design.** Deleting the row up front, or refusing to
338
+ * store it, would make every concurrent caller start its own `readdir` —
339
+ * turning the descriptor shortage `EMFILE` reports into a descriptor storm,
340
+ * i.e. answering the failure with more of its cause. Storing the in-flight
341
+ * promise keeps the collapse-N-callers-to-one-syscall property intact through
342
+ * the failure; evicting after it settles is what stops the *next* wave from
343
+ * inheriting a verdict about a moment that has passed.
344
+ *
345
+ * The alternative considered and rejected was a bounded retry inside this
346
+ * method. It re-issues the syscall *while the shortage is still in progress*
347
+ * (which is the storm again, only self-inflicted), it needs a backoff timer to
348
+ * be worth anything, and it hides latency inside a call every caller reads as
349
+ * a memo lookup. Letting the next ask pay one syscall is the same cost the
350
+ * cache already bounds: one per directory, per wave.
63
351
  *
64
352
  * @param dirPath - Directory to list
65
- * @returns Entry names, or `null` if the directory could not be read
353
+ * @returns The entry names, or why there are none to hand back
66
354
  */
67
355
  readdir(dirPath) {
68
356
  const cached = this.#listings.get(dirPath);
69
357
  if (cached !== undefined)
70
358
  return cached;
71
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- caller-validated path
72
- const pending = fs.readdir(dirPath).catch(() => null);
359
+ const listed = fs.readdir(dirPath);
360
+ const pending = listed
361
+ .then((names) => ({ outcome: 'listed', names }))
362
+ .catch(listingFailure);
73
363
  this.#listings.set(dirPath, pending);
74
- return pending;
364
+ return this.#forgetIfTransient(dirPath, pending);
365
+ }
366
+ /**
367
+ * Hand back `pending`'s answer, dropping the memo row first when the answer is
368
+ * a *transient* refusal.
369
+ *
370
+ * The row is stored by the caller before this is reached, so the wave that
371
+ * provoked the shortage shares that one syscall; this only decides whether a
372
+ * LATER wave inherits its verdict. Identity-guarded because a later ask may
373
+ * already have installed a fresh row, and deleting that one would discard a
374
+ * listing somebody is awaiting.
375
+ *
376
+ * @param dirPath - Directory the row is filed under
377
+ * @param pending - The row itself, already stored
378
+ * @returns The same listing `pending` settles to
379
+ */
380
+ async #forgetIfTransient(dirPath, pending) {
381
+ const listing = await pending;
382
+ if (isTransientRefusal(listing) && this.#listings.get(dirPath) === pending) {
383
+ this.#listings.delete(dirPath);
384
+ }
385
+ return listing;
386
+ }
387
+ }
388
+ /** Thrown when a link inside the tree being copied points outside it. */
389
+ export class CopyLinkEscapesSourceError extends VatError {
390
+ constructor(link, src) {
391
+ super('COPY_LINK_ESCAPES_SOURCE', `Refusing to copy ${link}: it is a symlink to a path outside ${src}. ` +
392
+ 'A copy follows links, so this would ship content the source tree does not own — ' +
393
+ 'replace the link with the files, or point it inside the tree.');
75
394
  }
76
395
  }
77
396
  /**
78
- * Recursively copy a directory
397
+ * Recursively copy a directory, following symlinks — contained to `src`.
398
+ *
399
+ * A link is copied as what it points at (a linked directory as its tree, a
400
+ * linked file as its bytes; a dangling link fails loudly in `stat`). Two
401
+ * refusals bound that: a link whose target is not under `src` throws
402
+ * {@link CopyLinkEscapesSourceError} — `scripts/etc -> /etc` used to copy
403
+ * `/etc` into `dist` — and a link that leads the walk back into a directory
404
+ * it already entered throws `DirectoryWalkRevisitedError` (`scripts/loop -> .`
405
+ * used to create `dest/loop/loop/…` until `ENAMETOOLONG`, writing every file
406
+ * at every level first). Adopter-authored trees reach this through
407
+ * `vat agent build`, so neither shape is exotic.
79
408
  *
80
409
  * @param src - Source directory path
81
410
  * @param dest - Destination directory path
@@ -84,15 +413,33 @@ export class FsLookupCache {
84
413
  * await copyDirectory('/source/dir', '/dest/dir');
85
414
  */
86
415
  export async function copyDirectory(src, dest) {
87
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths from validated sources
416
+ const walk = new FollowedWalk();
417
+ walk.enter(src);
418
+ await copyTree(src, dest, src, walk);
419
+ }
420
+ /** One level of {@link copyDirectory}; every directory it recurses into has been `enter`ed. */
421
+ async function copyTree(src, dest, root, walk) {
88
422
  await fs.mkdir(dest, { recursive: true });
89
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths from validated sources
90
423
  const entries = await fs.readdir(src, { withFileTypes: true });
91
424
  for (const entry of entries) {
92
425
  const srcPath = safePath.join(src, entry.name);
93
426
  const destPath = safePath.join(dest, entry.name);
94
- if (entry.isDirectory()) {
95
- await copyDirectory(srcPath, destPath);
427
+ // (Inline rather than `direntKindFollowing`: that module imports this one.)
428
+ let isDirectory = entry.isDirectory();
429
+ if (entry.isSymbolicLink()) {
430
+ isDirectory = (await fs.stat(srcPath)).isDirectory();
431
+ // Revisit first, so a link back into the tree is named as the loop it
432
+ // is; then containment, so a link out is named as the escape it is.
433
+ if (isDirectory)
434
+ walk.enter(srcPath);
435
+ if (isUnderRoot(root, srcPath) !== 'inside')
436
+ throw new CopyLinkEscapesSourceError(srcPath, root);
437
+ }
438
+ else if (isDirectory) {
439
+ walk.enter(srcPath);
440
+ }
441
+ if (isDirectory) {
442
+ await copyTree(srcPath, destPath, root, walk);
96
443
  }
97
444
  else {
98
445
  await fs.copyFile(srcPath, destPath);
@@ -100,61 +447,413 @@ export async function copyDirectory(src, dest) {
100
447
  }
101
448
  }
102
449
  /**
103
- * Verify that a file exists with the exact case-sensitive filename.
450
+ * The refusal a `readdir` that was refused stands for — errno, directory and
451
+ * whether a re-ask could answer differently, derived ONCE beside the errno list.
104
452
  *
105
- * On case-insensitive filesystems (Windows, macOS), a file might be found even if
106
- * the case doesn't match. This function checks that the actual filename on disk
107
- * matches the requested path exactly (case-sensitive).
453
+ * Shared by the spelling judge (through {@link absenceCauseFor}) and the crawl
454
+ * that defines the population (`file-crawler.ts`), so the two lanes cannot
455
+ * disagree about which refusals are transient.
108
456
  *
109
- * Answering requires listing the target's parent directory. Callers checking many
110
- * paths (every link in a corpus) hit the same handful of directories over and over,
111
- * so the listing comes from a caller-supplied {@link FsLookupCache}.
457
+ * @param listing - A `readdir` outcome that was refused
458
+ * @param directory - The directory that was asked about
459
+ * @returns The refusal, with `directory` forward-slashed
460
+ */
461
+ export function directoryRefusalFor(listing, directory) {
462
+ return {
463
+ kind: 'directory_unreadable',
464
+ code: listing.code,
465
+ directory: toForwardSlash(directory),
466
+ transient: TRANSIENT_LISTING_ERRNOS.has(listing.code),
467
+ };
468
+ }
469
+ /**
470
+ * The cause for a listing that produced no index.
112
471
  *
113
- * The cache parameter is **required rather than defaulted on purpose**: a default
114
- * would let an unmigrated call site silently keep the un-memoized behaviour, which
115
- * is a no-op wearing the shape of a fix. `new FsLookupCache()` per call reproduces
116
- * the old behaviour exactly, so migrating is mechanical — but it has to be a
117
- * decision someone made.
472
+ * @param listing - A `readdir` outcome that is not `listed`
473
+ * @param directory - The directory that was asked about
474
+ * @returns Which absence this is, and — when it is a refusal — its detail
475
+ */
476
+ function absenceCauseFor(listing, directory) {
477
+ if (listing.outcome === 'absent')
478
+ return { kind: 'no_such_entry' };
479
+ return directoryRefusalFor(listing, directory);
480
+ }
481
+ /**
482
+ * How much worse each spelling is than the one above it.
118
483
  *
119
- * @param filePath - Absolute path to the file to verify
484
+ * A path can be wrong in more than one way at once (`Café/Guide.md` against
485
+ * `café/guide.md`), and a report has to pick one verdict. The worst component
486
+ * wins: a case mismatch is broken on more machines than a normalization
487
+ * mismatch is, so reporting the milder one would understate what the author has
488
+ * to fix. The corrected path is carried either way, so nothing is lost.
489
+ */
490
+ const SPELLING_RANK = {
491
+ exact: 0,
492
+ normalized: 1,
493
+ case_mismatch: 2,
494
+ };
495
+ /** Whether this build failed in a way a later build could get past. */
496
+ function isTransientlyUnreadable(indexed) {
497
+ return (indexed.index === null &&
498
+ indexed.because.kind === 'directory_unreadable' &&
499
+ indexed.because.transient);
500
+ }
501
+ /** Record an entry under whichever of the three spellings it is first for. */
502
+ function indexEntry(index, entry) {
503
+ if (!index.exact.has(entry))
504
+ index.exact.set(entry, entry);
505
+ const folded = toNfc(entry);
506
+ if (!index.nfc.has(folded))
507
+ index.nfc.set(folded, entry);
508
+ const lowered = folded.toLowerCase();
509
+ if (!index.folded.has(lowered))
510
+ index.folded.set(lowered, entry);
511
+ }
512
+ /** Ask one indexed listing for a name, under each rule in turn. */
513
+ function lookupIn(index, name) {
514
+ const exact = index.exact.get(name);
515
+ if (exact !== undefined)
516
+ return { match: 'exact', actualName: exact };
517
+ const folded = toNfc(name);
518
+ const normalized = index.nfc.get(folded);
519
+ if (normalized !== undefined)
520
+ return { match: 'normalized', actualName: normalized };
521
+ const insensitive = index.folded.get(folded.toLowerCase());
522
+ return insensitive === undefined
523
+ ? { match: 'absent', because: { kind: 'no_such_entry' } }
524
+ : { match: 'case_mismatch', actualName: insensitive };
525
+ }
526
+ /**
527
+ * Every directory a run asks about, listed once and indexed once.
528
+ *
529
+ * ⚠️ **It owns the listings and never hands one out.** That is deliberate: the
530
+ * defect it replaced was a per-path scan over a shared raw array, and an
531
+ * implementation that cannot reach the array cannot scan it. The only ways to
532
+ * ask a question are {@link DirectorySpellingIndex.lookup} and
533
+ * {@link DirectorySpellingIndex.judgePath}, both `Map.get` over an index built
534
+ * at most once per directory — {@link DirectorySpellingIndex.directoriesIndexed}
535
+ * and {@link DirectorySpellingIndex.entriesIndexed} are what a test counts to
536
+ * prove the work did not go back to being per-path.
537
+ *
538
+ * **Instance-per-run, like the {@link FsLookupCache} it borrows** — it holds a
539
+ * snapshot of directory contents and must not outlive the run that took it.
540
+ */
541
+ export class DirectorySpellingIndex {
542
+ #fsCache;
543
+ /** Directory → its index, or why it has none. */
544
+ #indexes = new Map();
545
+ #directoriesIndexed = 0;
546
+ #entriesIndexed = 0;
547
+ constructor(fsCache) {
548
+ this.#fsCache = fsCache;
549
+ }
550
+ /**
551
+ * How many times a listing was turned into an index.
552
+ *
553
+ * Counted at the BUILD, not as `#indexes.size`: the size is the number of
554
+ * distinct directories asked about, which stays put even if every lookup
555
+ * rebuilds — the exact regression this number exists to catch.
556
+ */
557
+ get directoriesIndexed() {
558
+ return this.#directoriesIndexed;
559
+ }
560
+ /** How many directory entries were examined, across every index built. */
561
+ get entriesIndexed() {
562
+ return this.#entriesIndexed;
563
+ }
564
+ /** Every directory that has been listed, for never-reached-above-the-root pins. */
565
+ get indexedDirectories() {
566
+ return [...this.#indexes.keys()];
567
+ }
568
+ /**
569
+ * Ask what `directory` really calls `name`.
570
+ *
571
+ * @param directory - Absolute path of the directory to ask about
572
+ * @param name - One path component, spelled as the caller asked for it
573
+ * @returns Which rule matched and the entry's own spelling, or `absent`
574
+ */
575
+ async lookup(directory, name) {
576
+ const indexed = await this.#indexFor(directory);
577
+ return indexed.index === null
578
+ ? { match: 'absent', because: indexed.because }
579
+ : lookupIn(indexed.index, name);
580
+ }
581
+ /**
582
+ * Judge every component of `resolvedPath`, from `root` down.
583
+ *
584
+ * Each component is judged against the directory that actually holds it —
585
+ * which is the corrected spelling of the previous component, not the
586
+ * asked-for one, so a wrong directory name does not hide a wrong filename
587
+ * beneath it.
588
+ *
589
+ * ⛔ **It never looks above `root`.** The walk starts there and only
590
+ * descends, and a path that does not live under `root` is refused outright
591
+ * rather than walked from somewhere else: a verdict that depends on a
592
+ * directory above the root is a verdict that changes when the tree is moved.
593
+ * Pick a root the caller has already enumerated, and every component below it
594
+ * is one the *reference text* contributed — exactly the ones worth judging.
595
+ *
596
+ * @param root - Absolute path of a directory known to exist, and an ancestor
597
+ * of `resolvedPath` (or `resolvedPath` itself)
598
+ * @param resolvedPath - Absolute path to judge
599
+ * @returns The worst spelling defect on the path, plus both spellings of it
600
+ * @throws If `resolvedPath` does not live at or under `root`
601
+ */
602
+ async judgePath(root, resolvedPath) {
603
+ // `safePath.relative` already answers in forward slashes; saying so out loud
604
+ // is what makes both the traversal test and the split below safe on Windows.
605
+ const askedPath = toForwardSlash(safePath.relative(root, resolvedPath));
606
+ // The root itself: the caller enumerated it to get here, so it resolves,
607
+ // and there is no component to judge. Asking would mean listing its PARENT.
608
+ if (askedPath === '')
609
+ return { match: 'exact', askedPath, actualPath: askedPath };
610
+ // Tested as a whole SEGMENT rather than as a prefix: `startsWith('..')`
611
+ // would refuse a real directory named `..cache`.
612
+ const segments = toForwardSlash(askedPath).split('/');
613
+ if (segments[0] === '..') {
614
+ throw new Error(`Path spelling asked about "${askedPath}", which is above the walk root "${root}". ` +
615
+ `A verdict that depends on a directory above the root changes when the tree moves.`);
616
+ }
617
+ return await this.#walk(root, askedPath, segments);
618
+ }
619
+ /** The component-by-component descent behind {@link judgePath}. */
620
+ async #walk(root, askedPath, segments) {
621
+ const actual = [];
622
+ let worst = 'exact';
623
+ let directory = root;
624
+ for (const segment of segments) {
625
+ // Sequential by necessity: which directory holds the next component
626
+ // depends on how this one is really spelled. Every listing is memoized,
627
+ // so a run pays per DIRECTORY, not per path and not per component.
628
+ const found = await this.lookup(directory, segment);
629
+ if (found.match === 'absent') {
630
+ // The cause travels with the verdict rather than being re-derived: by
631
+ // the time a caller reports this, the directory that refused is
632
+ // several frames gone and nothing else can tell the two absences
633
+ // apart. So does what was learned ABOVE it — see `verified`.
634
+ return {
635
+ match: 'absent',
636
+ askedPath,
637
+ actualPath: '',
638
+ because: found.because,
639
+ verified: {
640
+ match: worst,
641
+ askedPath: segments.slice(0, actual.length).join('/'),
642
+ actualPath: actual.join('/'),
643
+ },
644
+ };
645
+ }
646
+ if (SPELLING_RANK[found.match] > SPELLING_RANK[worst])
647
+ worst = found.match;
648
+ actual.push(found.actualName);
649
+ directory = safePath.join(directory, found.actualName);
650
+ }
651
+ return { match: worst, askedPath, actualPath: actual.join('/') };
652
+ }
653
+ /**
654
+ * The index for one directory, built at most once.
655
+ *
656
+ * The promise — not the resolved value — is memoized, so two components
657
+ * resolving into the same directory concurrently share one listing and one
658
+ * build rather than racing to do both twice.
659
+ *
660
+ * ⚠️ **A transient refusal is dropped here as well as in the listing memo
661
+ * underneath, and both evictions are load-bearing.** This map caches the
662
+ * built INDEX, so evicting only `FsLookupCache`'s listing would leave the
663
+ * moment-in-time refusal pinned at precisely the layer every consumer reads —
664
+ * a fix that is real and invisible. Same settle-then-evict timing, and the
665
+ * same identity guard, for the same reason: see {@link FsLookupCache.readdir}.
666
+ */
667
+ async #indexFor(directory) {
668
+ const existing = this.#indexes.get(directory);
669
+ if (existing !== undefined)
670
+ return await existing;
671
+ const building = this.#build(directory);
672
+ this.#indexes.set(directory, building);
673
+ const indexed = await building;
674
+ if (isTransientlyUnreadable(indexed) && this.#indexes.get(directory) === building) {
675
+ this.#indexes.delete(directory);
676
+ }
677
+ return indexed;
678
+ }
679
+ /** List one directory and index every entry it holds. */
680
+ async #build(directory) {
681
+ this.#directoriesIndexed += 1;
682
+ const listing = await this.#fsCache.readdir(directory);
683
+ if (listing.outcome !== 'listed') {
684
+ // Two failures, two answers: a directory that is not there is absence,
685
+ // and a directory that refused to be listed is a question nobody got to
686
+ // ask. Mapping both to `no_such_entry` here is what used to report a
687
+ // link that opens as a missing file.
688
+ return { index: null, because: absenceCauseFor(listing, directory) };
689
+ }
690
+ const index = { exact: new Map(), nfc: new Map(), folded: new Map() };
691
+ for (const name of listing.names) {
692
+ indexEntry(index, name);
693
+ this.#entriesIndexed += 1;
694
+ }
695
+ return { index };
696
+ }
697
+ }
698
+ /**
699
+ * Where to start judging `target`, given that `referrer`'s own path came off
700
+ * the filesystem rather than out of a document.
701
+ *
702
+ * ⚠️ **The root is the deepest directory the two paths share, and that choice
703
+ * is doing real work in both directions.** Everything *above* it was enumerated
704
+ * (so judging it would compare disk against disk, and on a macOS crawl that
705
+ * routinely means reporting an NFD component nobody wrote); everything *below*
706
+ * it is what the reference text contributed, and is precisely what a
707
+ * misspelling can hide in.
708
+ *
709
+ * Falls back to the target's own parent — i.e. judging the basename alone, the
710
+ * weakest useful answer — when the two paths share no meaningful ancestor
711
+ * (different drives on Windows, or a relative path).
712
+ *
713
+ * @param referrer - Path of the file holding the reference
714
+ * @param target - Absolute path the reference resolved to
715
+ * @returns The directory to walk down from
716
+ */
717
+ export function spellingWalkRoot(referrer, target) {
718
+ const referrerDir = toForwardSlash(path.dirname(referrer)).split('/');
719
+ const targetDir = toForwardSlash(path.dirname(target)).split('/');
720
+ let shared = 0;
721
+ while (shared < referrerDir.length &&
722
+ shared < targetDir.length &&
723
+ referrerDir[shared] === targetDir[shared]) {
724
+ shared += 1;
725
+ }
726
+ // `< 2` rather than `=== 0`: a single shared segment is the filesystem root
727
+ // (`''` on POSIX) or the drive (`C:` on Windows), and walking down from there
728
+ // would list directories no caller owns.
729
+ return shared < 2 ? path.dirname(target) : targetDir.slice(0, shared).join('/');
730
+ }
731
+ /**
732
+ * The table key — derived in exactly one place so a filler and a judge cannot
733
+ * construct different ones for the same question.
734
+ */
735
+ function spellingKey(referrer, target) {
736
+ return `${spellingWalkRoot(referrer, target)}\0${target}`;
737
+ }
738
+ /**
739
+ * Judge every request's whole path — the only place I/O is legal for this fact,
740
+ * and the pass that must run *before* any judging.
741
+ *
742
+ * Distinct (root, target) pairs are walked **concurrently**, and every listing
743
+ * they need goes through the cache's own {@link DirectorySpellingIndex}
744
+ * ({@link FsLookupCache.spellingIndex}), so a directory holding N referenced
745
+ * targets is listed once, not N times, a directory on the path to M of them is
746
+ * listed once, not M times, and a caller that fills once per path still indexes
747
+ * each directory only once for the whole run.
748
+ *
749
+ * @param requests - Targets to judge, each paired with its referring file
120
750
  * @param fsCache - Per-run lookup cache (one instance per validation run)
121
- * @returns Object with exists flag and actual filename (or null if not found)
751
+ * @returns The filled table; empty input yields an empty table with no syscalls
752
+ */
753
+ export async function fillPathSpellings(requests, fsCache) {
754
+ const distinct = new Map();
755
+ for (const request of requests) {
756
+ const key = spellingKey(request.referrer, request.target);
757
+ if (!distinct.has(key))
758
+ distinct.set(key, request);
759
+ }
760
+ const index = fsCache.spellingIndex;
761
+ const table = new Map();
762
+ await Promise.all([...distinct].map(async ([key, request]) => {
763
+ const root = spellingWalkRoot(request.referrer, request.target);
764
+ table.set(key, await index.judgePath(root, request.target));
765
+ }));
766
+ return table;
767
+ }
768
+ /**
769
+ * Read the verdict for one reference out of an already-filled table. Pure.
122
770
  *
123
- * @example
124
- * ```typescript
125
- * // On case-insensitive filesystem with file "README.md"
126
- * const fsCache = new FsLookupCache();
127
- * const result1 = await verifyCaseSensitiveFilename('/project/README.md', fsCache);
128
- * // { exists: true, actualName: 'README.md' }
771
+ * **A miss throws rather than degrading to `absent`.** The fill set is derived
772
+ * from exactly the references the judge will be asked about, so a missing row
773
+ * is a programming error — a path judged that nobody filled. Degrading would
774
+ * report every such reference as *missing*: a wrong answer wearing the shape of
775
+ * a graceful degradation, and one no test of the verdict would catch.
129
776
  *
130
- * const result2 = await verifyCaseSensitiveFilename('/project/readme.md', fsCache);
131
- * // { exists: false, actualName: 'README.md' } - case mismatch!
132
- * ```
777
+ * @param table - Table filled by {@link fillPathSpellings}
778
+ * @param referrer - The file holding the reference
779
+ * @param target - The absolute path it resolved to
780
+ * @returns How faithfully the whole path is spelled
781
+ * @throws If `table` holds no row for this (referrer, target) pair
133
782
  */
134
- export async function verifyCaseSensitiveFilename(filePath, fsCache) {
135
- // Get parent directory and expected filename
136
- const parentDir = path.dirname(filePath);
137
- const expectedName = path.basename(filePath);
138
- // Read actual directory entries
139
- const entries = await fsCache.readdir(parentDir);
140
- if (entries === null) {
141
- // Parent directory doesn't exist (or can't be read)
142
- return { exists: false, actualName: null };
143
- }
144
- // Find the actual filename (case-sensitive exact match)
145
- const exactMatch = entries.find(entry => entry === expectedName);
146
- if (exactMatch) {
147
- // Found exact case match - file exists with correct case
148
- return { exists: true, actualName: exactMatch };
149
- }
150
- // No exact match - check for case-insensitive match
151
- const caseInsensitiveMatch = entries.find(entry => entry.toLowerCase() === expectedName.toLowerCase());
152
- // Return result:
153
- // - If case-insensitive match found: exists=false (wrong case), actualName=<actual>
154
- // - If no match at all: exists=false, actualName=null
155
- return {
156
- exists: false,
157
- actualName: caseInsensitiveMatch ?? null,
158
- };
783
+ export function pathSpellingFrom(table, referrer, target) {
784
+ const spelling = table.get(spellingKey(referrer, target));
785
+ if (spelling === undefined) {
786
+ throw new Error(`No path spelling for "${target}" (referenced from "${referrer}"). ` +
787
+ `Fill it with fillPathSpellings() before judging.`);
788
+ }
789
+ return spelling;
790
+ }
791
+ /**
792
+ * Canonicalize every path in `paths` — the only place I/O is legal for this
793
+ * fact, and the pass that must run *before* any judging.
794
+ *
795
+ * ⚠️ **Rows are keyed by the input path string exactly as given** — not a
796
+ * dirname, not a re-resolved form. {@link realpathFrom} looks that same string
797
+ * up, so any normalization applied here and not there is a silent miss (a loud
798
+ * one, in fact: the judge throws). Contrast {@link fillPathSpellings}, which
799
+ * keys by (walk root, target) *because* many references share one walk; here the
800
+ * answer is per path, so the path is the key.
801
+ *
802
+ * Distinct paths are canonicalized **concurrently**: the shape this replaces
803
+ * asked one path at a time at judgement time, which serialised every `realpath`
804
+ * behind the previous path's `await`. De-duplication is by path, so the same
805
+ * path passed N times costs one syscall; the call itself goes through
806
+ * {@link FsLookupCache.realpath}, which memoizes and shares in-flight promises
807
+ * across fills.
808
+ *
809
+ * @param paths - Paths to canonicalize
810
+ * @param fsCache - Per-run lookup cache (one instance per validation run)
811
+ * @returns The filled table; empty input yields an empty table with no syscalls
812
+ */
813
+ export async function fillRealpaths(paths, fsCache) {
814
+ const distinctPaths = new Set(paths);
815
+ const table = new Map();
816
+ await Promise.all([...distinctPaths].map(async (filePath) => {
817
+ table.set(filePath, await fsCache.realpath(filePath));
818
+ }));
819
+ return table;
820
+ }
821
+ /**
822
+ * Read the canonical path for `filePath` out of an already-filled table. Pure.
823
+ *
824
+ * **A miss throws rather than degrading to a recomputed realpath.** The fill set
825
+ * is derived from exactly the paths the judge will be asked about, so a missing
826
+ * key is a programming error — a path judged that nobody filled. Recomputing it
827
+ * would answer *correctly* and silently reintroduce the per-path syscall this
828
+ * column exists to remove: a regression no test of the verdict could catch,
829
+ * because the verdict would be identical, only slower.
830
+ *
831
+ * The row IS the answer here — nothing further has to judge it — so this lookup
832
+ * is itself the judge for this column.
833
+ *
834
+ * **The signature is not what keeps this free of I/O — a test is.** As with
835
+ * {@link pathSpellingFrom}, this module imports `node:fs` and
836
+ * `node:fs/promises` at module scope, so withholding a {@link FsLookupCache} from
837
+ * the parameter list prevents nothing. The guard is
838
+ * `packages/utils/test/fs-utils.test.ts` → *"judges from a filled table, reaching
839
+ * neither the async nor the sync realpath"*, which spies `nodeFs.realpath` and
840
+ * `nodeFs.realpathSync` on the module default objects, proves both instruments
841
+ * attached with a positive control, and asserts zero calls across judgement.
842
+ *
843
+ * @param table - Table filled by {@link fillRealpaths}
844
+ * @param filePath - Path being asked about, as it was handed to the fill
845
+ * @returns The canonical path, with forward slashes on every platform
846
+ * @throws If `table` holds no row for `filePath`
847
+ */
848
+ export function realpathFrom(table, filePath) {
849
+ // `undefined` can only mean "absent key": a filled row is always a string,
850
+ // because `FsLookupCache.realpath` falls back to the deepest existing
851
+ // ancestor's canonical path rather than leaving an unresolvable path without
852
+ // an answer.
853
+ const realPath = table.get(filePath);
854
+ if (realPath === undefined) {
855
+ throw new Error(`No canonical path for "${filePath}". Fill it with fillRealpaths() before judging.`);
856
+ }
857
+ return realPath;
159
858
  }
160
859
  //# sourceMappingURL=fs-utils.js.map