@animalabs/agent-framework 0.27.0 → 0.29.0

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 (344) hide show
  1. package/README.md +145 -41
  2. package/dist/src/agent.d.ts +29 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +73 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +3 -12
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/api/server.d.ts.map +1 -1
  10. package/dist/src/api/server.js +11 -5
  11. package/dist/src/api/server.js.map +1 -1
  12. package/dist/src/auto-continue.d.ts +5 -2
  13. package/dist/src/auto-continue.d.ts.map +1 -1
  14. package/dist/src/auto-continue.js +9 -5
  15. package/dist/src/auto-continue.js.map +1 -1
  16. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  17. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/runner.js +7 -5
  19. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +3 -2
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/cut-off-round.d.ts +24 -0
  24. package/dist/src/cut-off-round.d.ts.map +1 -0
  25. package/dist/src/cut-off-round.js +87 -0
  26. package/dist/src/cut-off-round.js.map +1 -0
  27. package/dist/src/framework.d.ts +468 -125
  28. package/dist/src/framework.d.ts.map +1 -1
  29. package/dist/src/framework.js +3651 -1111
  30. package/dist/src/framework.js.map +1 -1
  31. package/dist/src/gate/event-gate.d.ts +133 -13
  32. package/dist/src/gate/event-gate.d.ts.map +1 -1
  33. package/dist/src/gate/event-gate.js +654 -59
  34. package/dist/src/gate/event-gate.js.map +1 -1
  35. package/dist/src/gate/index.d.ts +3 -2
  36. package/dist/src/gate/index.d.ts.map +1 -1
  37. package/dist/src/gate/index.js +2 -1
  38. package/dist/src/gate/index.js.map +1 -1
  39. package/dist/src/gate/reply-judge.d.ts +116 -0
  40. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  41. package/dist/src/gate/reply-judge.js +467 -0
  42. package/dist/src/gate/reply-judge.js.map +1 -0
  43. package/dist/src/gate/types.d.ts +173 -2
  44. package/dist/src/gate/types.d.ts.map +1 -1
  45. package/dist/src/heartbeat/heartbeat.d.ts +202 -0
  46. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  47. package/dist/src/heartbeat/heartbeat.js +778 -0
  48. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  50. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  51. package/dist/src/heartbeat/reminder-time.js +107 -0
  52. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  53. package/dist/src/held-writes.d.ts +3 -3
  54. package/dist/src/held-writes.js +3 -3
  55. package/dist/src/host-presentation.d.ts +170 -0
  56. package/dist/src/host-presentation.d.ts.map +1 -0
  57. package/dist/src/host-presentation.js +561 -0
  58. package/dist/src/host-presentation.js.map +1 -0
  59. package/dist/src/index.d.ts +5 -1
  60. package/dist/src/index.d.ts.map +1 -1
  61. package/dist/src/index.js +3 -1
  62. package/dist/src/index.js.map +1 -1
  63. package/dist/src/line-reader.d.ts +4 -0
  64. package/dist/src/line-reader.d.ts.map +1 -0
  65. package/dist/src/line-reader.js +59 -0
  66. package/dist/src/line-reader.js.map +1 -0
  67. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  68. package/dist/src/mcpl/capability-mask.js +3 -2
  69. package/dist/src/mcpl/capability-mask.js.map +1 -1
  70. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  71. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  72. package/dist/src/mcpl/channel-registry.js +133 -54
  73. package/dist/src/mcpl/channel-registry.js.map +1 -1
  74. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  75. package/dist/src/mcpl/conversation-router.js +2 -1
  76. package/dist/src/mcpl/conversation-router.js.map +1 -1
  77. package/dist/src/mcpl/convert-block.d.ts +1 -0
  78. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  79. package/dist/src/mcpl/convert-block.js +16 -4
  80. package/dist/src/mcpl/convert-block.js.map +1 -1
  81. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  82. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  83. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  84. package/dist/src/mcpl/inbound-source.d.ts +29 -11
  85. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  86. package/dist/src/mcpl/inbound-source.js +43 -70
  87. package/dist/src/mcpl/inbound-source.js.map +1 -1
  88. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  89. package/dist/src/mcpl/modern-connection.js +11 -7
  90. package/dist/src/mcpl/modern-connection.js.map +1 -1
  91. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  92. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  93. package/dist/src/mcpl/protocol-family.js +25 -3
  94. package/dist/src/mcpl/protocol-family.js.map +1 -1
  95. package/dist/src/mcpl/push-handler.js +2 -2
  96. package/dist/src/mcpl/push-handler.js.map +1 -1
  97. package/dist/src/mcpl/references.d.ts +12 -40
  98. package/dist/src/mcpl/references.d.ts.map +1 -1
  99. package/dist/src/mcpl/references.js +29 -23
  100. package/dist/src/mcpl/references.js.map +1 -1
  101. package/dist/src/mcpl/server-connection.d.ts +18 -8
  102. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  103. package/dist/src/mcpl/server-connection.js +70 -30
  104. package/dist/src/mcpl/server-connection.js.map +1 -1
  105. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  106. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  107. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  108. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  109. package/dist/src/mcpl/tags.d.ts +9 -2
  110. package/dist/src/mcpl/tags.d.ts.map +1 -1
  111. package/dist/src/mcpl/tags.js +12 -2
  112. package/dist/src/mcpl/tags.js.map +1 -1
  113. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  114. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  115. package/dist/src/mcpl/tool-presence.js +150 -80
  116. package/dist/src/mcpl/tool-presence.js.map +1 -1
  117. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  118. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  119. package/dist/src/mcpl/tool-signature.js +60 -30
  120. package/dist/src/mcpl/tool-signature.js.map +1 -1
  121. package/dist/src/mcpl/toolset-host.d.ts +88 -55
  122. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  123. package/dist/src/mcpl/toolset-host.js +220 -111
  124. package/dist/src/mcpl/toolset-host.js.map +1 -1
  125. package/dist/src/mcpl/toolsets-module.d.ts +27 -35
  126. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  127. package/dist/src/mcpl/toolsets-module.js +313 -91
  128. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  129. package/dist/src/mcpl/transport.d.ts +1 -1
  130. package/dist/src/mcpl/transport.d.ts.map +1 -1
  131. package/dist/src/mcpl/transport.js +16 -16
  132. package/dist/src/mcpl/transport.js.map +1 -1
  133. package/dist/src/mcpl/types.d.ts +31 -13
  134. package/dist/src/mcpl/types.d.ts.map +1 -1
  135. package/dist/src/mcpl/types.js.map +1 -1
  136. package/dist/src/module-registry.d.ts +10 -8
  137. package/dist/src/module-registry.d.ts.map +1 -1
  138. package/dist/src/module-registry.js +15 -9
  139. package/dist/src/module-registry.js.map +1 -1
  140. package/dist/src/modules/api/index.d.ts.map +1 -1
  141. package/dist/src/modules/api/index.js +4 -2
  142. package/dist/src/modules/api/index.js.map +1 -1
  143. package/dist/src/modules/discord/index.d.ts.map +1 -1
  144. package/dist/src/modules/discord/index.js +33 -25
  145. package/dist/src/modules/discord/index.js.map +1 -1
  146. package/dist/src/modules/history/index.d.ts.map +1 -1
  147. package/dist/src/modules/history/index.js +30 -17
  148. package/dist/src/modules/history/index.js.map +1 -1
  149. package/dist/src/modules/sender-name.d.ts +7 -0
  150. package/dist/src/modules/sender-name.d.ts.map +1 -0
  151. package/dist/src/modules/sender-name.js +10 -0
  152. package/dist/src/modules/sender-name.js.map +1 -0
  153. package/dist/src/modules/workspace/answers.d.ts +26 -0
  154. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  155. package/dist/src/modules/workspace/answers.js +138 -0
  156. package/dist/src/modules/workspace/answers.js.map +1 -0
  157. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  158. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  159. package/dist/src/modules/workspace/branch-intent.js +6 -2
  160. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  161. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +59 -24
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  166. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  168. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  169. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  170. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/edit-text.js +4 -2
  172. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  173. package/dist/src/modules/workspace/effects.d.ts +4 -2
  174. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/effects.js +67 -50
  176. package/dist/src/modules/workspace/effects.js.map +1 -1
  177. package/dist/src/modules/workspace/index.d.ts +63 -51
  178. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/index.js +980 -547
  180. package/dist/src/modules/workspace/index.js.map +1 -1
  181. package/dist/src/modules/workspace/observe.d.ts +125 -82
  182. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/observe.js +465 -241
  184. package/dist/src/modules/workspace/observe.js.map +1 -1
  185. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/read-page.js +7 -1
  187. package/dist/src/modules/workspace/read-page.js.map +1 -1
  188. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  189. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/reconcile.js +156 -50
  191. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  192. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  193. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/result-text.js.map +1 -1
  195. package/dist/src/modules/workspace/sync.d.ts +9 -0
  196. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/sync.js +14 -0
  198. package/dist/src/modules/workspace/sync.js.map +1 -1
  199. package/dist/src/modules/workspace/types.d.ts +96 -43
  200. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/types.js.map +1 -1
  202. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  203. package/dist/src/modules/workspace/watcher.js +8 -3
  204. package/dist/src/modules/workspace/watcher.js.map +1 -1
  205. package/dist/src/notice-tags.d.ts +79 -0
  206. package/dist/src/notice-tags.d.ts.map +1 -0
  207. package/dist/src/notice-tags.js +83 -0
  208. package/dist/src/notice-tags.js.map +1 -0
  209. package/dist/src/prose-drafts.d.ts +4 -3
  210. package/dist/src/prose-drafts.d.ts.map +1 -1
  211. package/dist/src/prose-drafts.js +3 -2
  212. package/dist/src/prose-drafts.js.map +1 -1
  213. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  214. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  215. package/dist/src/reasoning-loss-notice.js +52 -0
  216. package/dist/src/reasoning-loss-notice.js.map +1 -0
  217. package/dist/src/reasoning-loss.d.ts +24 -0
  218. package/dist/src/reasoning-loss.d.ts.map +1 -0
  219. package/dist/src/reasoning-loss.js +102 -0
  220. package/dist/src/reasoning-loss.js.map +1 -0
  221. package/dist/src/send-tools.d.ts +3 -2
  222. package/dist/src/send-tools.d.ts.map +1 -1
  223. package/dist/src/send-tools.js +3 -2
  224. package/dist/src/send-tools.js.map +1 -1
  225. package/dist/src/sent-rows.js +1 -1
  226. package/dist/src/silent-heartbeat.d.ts +1 -53
  227. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  228. package/dist/src/silent-heartbeat.js +56 -3
  229. package/dist/src/silent-heartbeat.js.map +1 -1
  230. package/dist/src/supplied-text.d.ts +35 -0
  231. package/dist/src/supplied-text.d.ts.map +1 -0
  232. package/dist/src/supplied-text.js +141 -0
  233. package/dist/src/supplied-text.js.map +1 -0
  234. package/dist/src/thinking-drops.d.ts +4 -11
  235. package/dist/src/thinking-drops.d.ts.map +1 -1
  236. package/dist/src/thinking-drops.js +7 -14
  237. package/dist/src/thinking-drops.js.map +1 -1
  238. package/dist/src/tool-args.d.ts +12 -0
  239. package/dist/src/tool-args.d.ts.map +1 -1
  240. package/dist/src/tool-args.js +39 -4
  241. package/dist/src/tool-args.js.map +1 -1
  242. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  243. package/dist/src/tool-image-ledger.js +2 -1
  244. package/dist/src/tool-image-ledger.js.map +1 -1
  245. package/dist/src/tool-presentation.d.ts.map +1 -1
  246. package/dist/src/tool-presentation.js +5 -4
  247. package/dist/src/tool-presentation.js.map +1 -1
  248. package/dist/src/tool-result-guard.d.ts +8 -4
  249. package/dist/src/tool-result-guard.d.ts.map +1 -1
  250. package/dist/src/tool-result-guard.js +23 -11
  251. package/dist/src/tool-result-guard.js.map +1 -1
  252. package/dist/src/tune-out/coordinator.d.ts +3 -2
  253. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  254. package/dist/src/tune-out/coordinator.js +24 -26
  255. package/dist/src/tune-out/coordinator.js.map +1 -1
  256. package/dist/src/tune-out/tools.d.ts +4 -1
  257. package/dist/src/tune-out/tools.d.ts.map +1 -1
  258. package/dist/src/types/agent.d.ts +63 -18
  259. package/dist/src/types/agent.d.ts.map +1 -1
  260. package/dist/src/types/events.d.ts +18 -8
  261. package/dist/src/types/events.d.ts.map +1 -1
  262. package/dist/src/types/framework.d.ts +58 -0
  263. package/dist/src/types/framework.d.ts.map +1 -1
  264. package/dist/src/types/module.d.ts +27 -13
  265. package/dist/src/types/module.d.ts.map +1 -1
  266. package/dist/src/types/trace.d.ts +34 -1
  267. package/dist/src/types/trace.d.ts.map +1 -1
  268. package/package.json +3 -3
  269. package/src/agent.ts +76 -12
  270. package/src/api/mcp-server-core.ts +3 -14
  271. package/src/api/server.ts +11 -5
  272. package/src/auto-continue.ts +12 -5
  273. package/src/code-execution/isolate/runner.ts +7 -5
  274. package/src/code-execution/isolate/scripts.ts +3 -2
  275. package/src/cut-off-round.ts +98 -0
  276. package/src/framework.ts +3772 -1138
  277. package/src/gate/event-gate.ts +705 -60
  278. package/src/gate/index.ts +9 -1
  279. package/src/gate/reply-judge.ts +512 -0
  280. package/src/gate/types.ts +176 -3
  281. package/src/heartbeat/heartbeat.ts +843 -0
  282. package/src/heartbeat/reminder-time.ts +107 -0
  283. package/src/held-writes.ts +3 -3
  284. package/src/host-presentation.ts +605 -0
  285. package/src/index.ts +5 -1
  286. package/src/line-reader.ts +58 -0
  287. package/src/mcpl/capability-mask.ts +3 -2
  288. package/src/mcpl/channel-registry.ts +139 -53
  289. package/src/mcpl/conversation-router.ts +2 -1
  290. package/src/mcpl/convert-block.ts +17 -4
  291. package/src/mcpl/hook-orchestrator.ts +4 -3
  292. package/src/mcpl/inbound-source.ts +45 -70
  293. package/src/mcpl/modern-connection.ts +11 -7
  294. package/src/mcpl/protocol-family.ts +24 -3
  295. package/src/mcpl/push-handler.ts +2 -2
  296. package/src/mcpl/references.ts +31 -23
  297. package/src/mcpl/server-connection.ts +76 -34
  298. package/src/mcpl/skip-reply-wake.ts +35 -0
  299. package/src/mcpl/tags.ts +13 -2
  300. package/src/mcpl/tool-presence.ts +149 -82
  301. package/src/mcpl/tool-signature.ts +64 -32
  302. package/src/mcpl/toolset-host.ts +212 -110
  303. package/src/mcpl/toolsets-module.ts +277 -91
  304. package/src/mcpl/transport.ts +18 -14
  305. package/src/mcpl/types.ts +32 -13
  306. package/src/module-registry.ts +14 -9
  307. package/src/modules/api/index.ts +4 -2
  308. package/src/modules/discord/index.ts +37 -25
  309. package/src/modules/history/index.ts +30 -18
  310. package/src/modules/sender-name.ts +9 -0
  311. package/src/modules/workspace/answers.ts +151 -0
  312. package/src/modules/workspace/branch-intent.ts +10 -5
  313. package/src/modules/workspace/direct-write.ts +59 -23
  314. package/src/modules/workspace/disk-agreement.ts +52 -21
  315. package/src/modules/workspace/edit-text.ts +7 -5
  316. package/src/modules/workspace/effects.ts +69 -50
  317. package/src/modules/workspace/index.ts +966 -556
  318. package/src/modules/workspace/observe.ts +497 -242
  319. package/src/modules/workspace/read-page.ts +7 -1
  320. package/src/modules/workspace/reconcile.ts +184 -49
  321. package/src/modules/workspace/result-text.ts +2 -0
  322. package/src/modules/workspace/sync.ts +16 -0
  323. package/src/modules/workspace/types.ts +96 -43
  324. package/src/modules/workspace/watcher.ts +8 -3
  325. package/src/notice-tags.ts +84 -0
  326. package/src/prose-drafts.ts +4 -3
  327. package/src/reasoning-loss-notice.ts +64 -0
  328. package/src/reasoning-loss.ts +120 -0
  329. package/src/send-tools.ts +3 -2
  330. package/src/sent-rows.ts +1 -1
  331. package/src/silent-heartbeat.ts +4 -3
  332. package/src/supplied-text.ts +154 -0
  333. package/src/thinking-drops.ts +10 -16
  334. package/src/tool-args.ts +38 -4
  335. package/src/tool-image-ledger.ts +2 -1
  336. package/src/tool-presentation.ts +5 -4
  337. package/src/tool-result-guard.ts +26 -11
  338. package/src/tune-out/coordinator.ts +25 -27
  339. package/src/tune-out/tools.ts +4 -1
  340. package/src/types/agent.ts +63 -18
  341. package/src/types/events.ts +18 -8
  342. package/src/types/framework.ts +59 -0
  343. package/src/types/module.ts +28 -13
  344. package/src/types/trace.ts +36 -1
@@ -47,6 +47,7 @@ import { lstat, mkdir, open, readdir, readlink, realpath, stat, statfs } from 'n
47
47
  import type { FileHandle } from 'node:fs/promises';
48
48
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
49
49
  import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
50
+ import { OUTSIDE_STEP, UNFOLLOWED_STEP, errnoClause, errnoCode, systemWords } from './answers.js';
50
51
 
51
52
  export interface MountView {
52
53
  /**
@@ -84,6 +85,8 @@ export interface MountView {
84
85
  nested?: readonly NestedMount[];
85
86
  }
86
87
 
88
+ export type NestedDir = { dir: string; guessed?: true };
89
+
87
90
  /**
88
91
  * A mount nested in another: its directory in one of its spellings,
89
92
  * mount-relative and `/`-separated, its name, its real root, which every
@@ -94,7 +97,7 @@ export interface MountView {
94
97
  * it: the mount is nested by its configured path alone, since through its
95
98
  * real root it would claim this mount's every path.
96
99
  */
97
- export type NestedMount = { dir: string; name: string; real?: string; include?: readonly string[] };
100
+ export type NestedMount = NestedDir & { name: string; real?: string; include?: readonly string[] };
98
101
 
99
102
  export type StatFingerprint = Omit<Fingerprint, 'hashedAt'>;
100
103
 
@@ -112,7 +115,17 @@ export type Observation =
112
115
  | { kind: 'absent' }
113
116
  /** `mode`: the file's permission bits (`st_mode & 0o777`). */
114
117
  | { kind: 'file'; size: number; mtimeMs: number; mode: number; fp: StatFingerprint }
115
- | { kind: 'unobserved'; reason: string };
118
+ | ({ kind: 'unobserved' } & NotShown);
119
+
120
+ export interface Cause {
121
+ errno?: string;
122
+ again?: true;
123
+ ownStep?: true;
124
+ }
125
+
126
+ export interface NotShown extends Cause {
127
+ reason: string;
128
+ }
116
129
 
117
130
  export interface ReadResult {
118
131
  kind: 'read';
@@ -145,18 +158,30 @@ export const RACY_WINDOW_MS = 2000;
145
158
 
146
159
  export const OUTSIDE_PARENT = 'a parent directory resolves outside the mount';
147
160
  export const NOT_FOLLOWED = 'a symlink, which this mount does not follow';
161
+ export const DIRECTORY = 'a directory';
162
+ export const NOT_REGULAR = 'not a regular file';
163
+ export const LEAVES_MOUNT = 'a symlink that leaves the mount';
164
+ export const NOT_A_DIRECTORY = 'a parent path is not a directory';
148
165
  export const ROOT_UNAVAILABLE = 'the mount root is unavailable';
149
166
  export const ROOT_REPLACED =
150
167
  'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' +
151
168
  'reconnect it; if it was replaced on purpose, sync the mount with acceptRoot to take it as it is now';
152
- const CHANGED = 'it changed while it was observed';
153
- const CHANGED_WALK = 'the directory changed while the walk ran';
154
- const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
169
+ export const CHANGED = 'it changed while it was observed';
170
+ export const CHANGED_WALK = 'the directory changed while the walk ran';
171
+ export const FILE_CHANGED = 'the file changed while it was read';
172
+ export const RESOLVES_OUTSIDE = 'the directory resolves outside the mount';
173
+ export const LINK_TO_DIRECTORY = 'a symlink to a directory, not walked';
174
+ export const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
155
175
 
156
176
  export function errno(err: unknown): string | undefined {
157
177
  return (err as { code?: string } | null)?.code;
158
178
  }
159
179
 
180
+ function failedOn(what: string, err: unknown): NotShown {
181
+ const code = errno(err);
182
+ return { reason: `${what} (${systemWords(code, err)})`, ...(code !== undefined ? { errno: code } : {}) };
183
+ }
184
+
160
185
  export function contained(rootReal: string, candidate: string): boolean {
161
186
  return candidate === rootReal || candidate.startsWith(rootReal.endsWith(sep) ? rootReal : rootReal + sep);
162
187
  }
@@ -258,7 +283,9 @@ export async function linkRefusal(
258
283
  /**
259
284
  * Where a symlink the walk found leads, for its refusal: to a path inside
260
285
  * the mount (mount-relative, as disk spells it, and whether it's a
261
- * directory), outside the mount, or nowhere it can be resolved to. One
286
+ * directory), outside the mount, or nowhere (nothing is there, or the links
287
+ * loop); null where the realpath fails otherwise, as on a directory this
288
+ * process can't search, so where it leads isn't known. One
262
289
  * realpath, one stat and the respelling, made only when refusing. realpath
263
290
  * keeps the spelling the link's target gives, which on a disk that folds
264
291
  * names need not be disk's, so the target is respelled before anything
@@ -266,9 +293,14 @@ export async function linkRefusal(
266
293
  */
267
294
  export type LinkLeads = { kind: 'inside'; target: string; directory: boolean } | { kind: 'outside' } | { kind: 'nowhere' };
268
295
 
269
- export async function linkLeads(root: string, rootReal: string, link: string): Promise<LinkLeads> {
270
- const leads = await realpath(join(root, link)).catch(() => null);
271
- if (leads === null) return { kind: 'nowhere' };
296
+ export async function linkLeads(root: string, rootReal: string, link: string): Promise<LinkLeads | null> {
297
+ let leads: string;
298
+ try {
299
+ leads = await realpath(join(root, link));
300
+ } catch (err) {
301
+ const code = errno(err);
302
+ return code === 'ENOENT' || code === 'ENOTDIR' || code === 'ELOOP' ? { kind: 'nowhere' } : null;
303
+ }
272
304
  if (!contained(rootReal, leads)) return { kind: 'outside' };
273
305
  const directory = await stat(leads).then((info) => info.isDirectory(), () => false);
274
306
  return { kind: 'inside', target: await diskSpelling(rootReal, relative(rootReal, leads).split(sep).join('/')), directory };
@@ -279,21 +311,23 @@ export async function linkLeads(root: string, rootReal: string, link: string): P
279
311
  * innermost where nested mounts nest, as #460 picks one; undefined when none
280
312
  * does.
281
313
  */
282
- export function nestedHolder<N extends { dir: string; name: string }>(target: string, nested: readonly N[]): N | undefined {
283
- return nested
284
- .filter((n) => target === n.dir || target.startsWith(`${n.dir}/`))
285
- .sort((a, b) => b.dir.length - a.dir.length)[0];
314
+ export function nestedHolder<N extends NestedDir & { name: string }>(target: string, nested: readonly N[]): N | undefined {
315
+ return nestedHolders(target, nested)[0];
286
316
  }
287
317
 
288
- /**
289
- * Whether a link's refusal names `holder`, the nested mount whose directory
290
- * holds the link's target. The target's path is one of that mount's own, so
291
- * its include decides: the refusal names a directory, or a file the include
292
- * covers. A refusal's code follows the same test (linkReadError's
293
- * `other_mount`), so the code says no more than the message.
294
- */
295
- export function namesHolder(leads: { target: string; directory: boolean }, holder: { dir: string; include?: readonly string[] }): boolean {
296
- return namesNestedPlace(leads.target.slice(holder.dir.length + 1), holder.include, leads.directory);
318
+ function nestedHolders<N extends NestedDir>(target: string, nested: readonly N[]): N[] {
319
+ const segments = target.split('/');
320
+ return mayHold(nested.filter((n) => beneathNested(segments, n)), (n) => n.dir.split('/').length, (n) => !n.guessed && surelyBeneath(segments, n.dir.split('/')));
321
+ }
322
+
323
+ function mayHold<T>(holders: readonly T[], depth: (holder: T) => number, sure: (holder: T) => boolean): T[] {
324
+ const sorted = [...holders].sort((a, b) => depth(b) - depth(a) || Number(sure(b)) - Number(sure(a)));
325
+ const surely = sorted.findIndex(sure);
326
+ return surely === -1 ? sorted : sorted.slice(0, surely + 1);
327
+ }
328
+
329
+ export function namesHolder(leads: { target: string; directory: boolean }, nested: readonly NestedMount[]): boolean {
330
+ return nestedHolders(leads.target, nested).every((holder) => namesNestedPlace(restBeneath(leads.target, holder), holder.include, leads.directory));
297
331
  }
298
332
 
299
333
  /**
@@ -312,18 +346,12 @@ export function namesNestedPlace(rest: string, include: readonly string[] | unde
312
346
  return directory || insideMount(rest, include);
313
347
  }
314
348
 
315
- /**
316
- * Whether a path of a mount is one its passes decide and its listings may
317
- * name: inside its include, and, in a nested mount's directory, a place
318
- * that mount names (namesNestedPlace), its directory included. History the
319
- * mount took in before another was nested there, which that mount's
320
- * include leaves out, is no mount's, so no pass decides it, and no listing
321
- * names it or a directory only it implies.
322
- */
323
349
  export function withinIncludes(view: Pick<MountView, 'include' | 'nested'>, path: string): boolean {
324
350
  if (!insideMount(path, view.include)) return false;
325
- const holder = nestedHolder(path, view.nested ?? []);
326
- return holder === undefined || path === holder.dir || namesNestedPlace(path.slice(holder.dir.length + 1), holder.include, false);
351
+ return nestedHolders(path, view.nested ?? []).every((holder) => {
352
+ const rest = restBeneath(path, holder);
353
+ return rest === '' || namesNestedPlace(rest, holder.include, false);
354
+ });
327
355
  }
328
356
 
329
357
  /**
@@ -394,16 +422,23 @@ export async function coversLanding(view: Pick<MountView, 'include'>, rootReal:
394
422
 
395
423
  /**
396
424
  * Why a path is refused for what linkOnTheWay found, given where a link
397
- * leads (linkLeads). For a link, the link is the subject, with where it
398
- * leads: for a link inside the mount, its target, and for a link to a
399
- * directory there, the setting the mount's host can change (the link stands
400
- * among the path's directories, so following a link to a file can't make the
401
- * path beneath it exist); for one leading outside the mount, that it does;
402
- * for one that can't be resolved, nothing more. A target in a nested mount's
403
- * directory (`nested`) is that mount's: the refusal names the mount and the
404
- * path to use there in place of the link, and offers no setting, since
405
- * following the link would only reach into another mount's directory. For a
406
- * directory the walk couldn't ask, which one, and the answer it got. Paths
425
+ * leads (linkLeads), and the way on. For a link, the link is the subject,
426
+ * with where it leads: for a link inside the mount, its target, as the path
427
+ * to use in its place, and for a link to a directory there, the setting
428
+ * whoever runs the host can change too; for a link to something that isn't
429
+ * a directory, that nothing lies beneath it (the link stands among the
430
+ * path's directories, so following it can't make the path beneath it
431
+ * exist); for one leading outside the mount, that it does, and that
432
+ * whoever runs the host can mount what it leads to, or a directory that
433
+ * holds it; for one leading nowhere, that whoever runs the host can remove it or make
434
+ * it lead to what it stood for; for one whose target the refusal doesn't
435
+ * tell, what whoever runs the host can do wherever it leads
436
+ * (UNFOLLOWED_STEP).
437
+ * A target in a nested mount's directory (`nested`) is that mount's: the
438
+ * refusal names the mount and the path to use there in place of the link,
439
+ * and offers no setting, since following the link would only reach into
440
+ * another mount's directory. For a directory the walk couldn't ask, which
441
+ * one, the answer it got, and the errno's way on (errnoClause). Paths
407
442
  * are spelled as the tools take them, from the mount's name. On a mount with
408
443
  * an include, an inside target is named only where a tool could name it: a
409
444
  * file the include covers, or a directory, which a listing names whatever
@@ -414,22 +449,33 @@ export async function coversLanding(view: Pick<MountView, 'include'>, rootReal:
414
449
  */
415
450
  export function linkWords(name: string, found: LinkFound, leads: LinkLeads | null, nested: readonly NestedMount[] = [], include?: readonly string[]): string {
416
451
  const spell = (rel: string) => `"${rel === '' ? name : `${name}/${rel}`}"`;
417
- if (!('link' in found)) return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${found.code})`;
452
+ if (!('link' in found)) {
453
+ const code = errnoCode({ code: found.code });
454
+ return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${systemWords(code, found.code)}); ${errnoClause(code, 'calling again', 'calling again then gets past it')}`;
455
+ }
418
456
  const link = `${spell(found.link)} is a symlink`;
419
- if (leads === null || leads.kind === 'nowhere') return `${link}, which this mount does not follow`;
420
- if (leads.kind === 'outside') return `${link} leading outside the mount, which this mount does not follow`;
457
+ const unknown = `${link}, which this mount does not follow; ${UNFOLLOWED_STEP}`;
458
+ if (leads === null) return unknown;
459
+ if (leads.kind === 'nowhere') {
460
+ return `${link} that leads nowhere, which this mount does not follow; whoever runs this host can remove it or make it lead to what it stood for`;
461
+ }
462
+ if (leads.kind === 'outside') {
463
+ return `${link} leading outside the mount, which this mount does not follow; followed or not, ${OUTSIDE_STEP}`;
464
+ }
421
465
  const holder = nestedHolder(leads.target, nested);
422
466
  if (holder) {
423
- if (!namesHolder(leads, holder)) return `${link}, which this mount does not follow`;
424
- const rest = leads.target.slice(holder.dir.length + 1);
425
- return `${link} to ${spell(leads.target)}, which this mount does not follow; it lies in mount "${holder.name}", ` +
426
- `nested in mount "${name}": use "${rest ? `${holder.name}/${rest}` : holder.name}" in place of ${spell(found.link)}`;
467
+ if (!namesHolder(leads, nested)) return unknown;
468
+ const rest = restBeneath(leads.target, holder);
469
+ const guess = nestedGuess(name, leads.target.split('/'), holder);
470
+ return `${link} to ${spell(leads.target)}, which this mount does not follow; it ${guess === undefined ? 'lies' : 'may lie'} in mount ` +
471
+ `"${holder.name}", nested in mount "${name}": ${nestedStep({ name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, guess }, { after: ` in place of ${spell(found.link)}` })}`;
427
472
  }
428
- if (!leads.directory && !insideMount(leads.target, include)) return `${link}, which this mount does not follow`;
429
- // Following it serves only what the include covers where it leads (judgedAt).
430
- const setting = !leads.directory ? '' : include === undefined ? "; the mount's host can set followSymlinks: true"
431
- : "; the mount's host can set followSymlinks: true, for what the include covers where it leads";
432
- return `${link} to ${spell(leads.target)}, which this mount does not follow${setting}`;
473
+ if (!leads.directory && !insideMount(leads.target, include)) return unknown;
474
+ if (!leads.directory) return `${link} to ${spell(leads.target)}, which this mount does not follow; that isn't a directory, so nothing lies beneath it`;
475
+ // Either way serves only what the include covers where it leads (judgedAt).
476
+ const covered = include === undefined ? '' : '; either way, the mount reaches only what its include covers there';
477
+ return `${link} to ${spell(leads.target)}, which this mount does not follow; use ${spell(leads.target)} in place of ${spell(found.link)}, ` +
478
+ `or whoever runs this host can set followSymlinks: true on mount "${name}"${covered}`;
433
479
  }
434
480
 
435
481
  /**
@@ -446,26 +492,85 @@ export async function shownLeads(root: string, rootReal: string, found: LinkFoun
446
492
  }
447
493
 
448
494
  /** Why a path is refused for what linkOnTheWay found (linkWords, with where the link leads, as shownLeads tells it). */
449
- export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested' | 'include'>, rootReal: string, found: LinkFound): Promise<string> {
450
- return linkWords(view.name, found, await shownLeads(view.root, rootReal, found, view.include), view.nested, view.include);
495
+ export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested' | 'include'>, rootReal: string, found: LinkFound): Promise<NotShown> {
496
+ return { reason: linkWords(view.name, found, await shownLeads(view.root, rootReal, found, view.include), view.nested, view.include), ownStep: true };
497
+ }
498
+
499
+ export function realSegments(path: string): string[] {
500
+ return path.split(sep).filter((segment, i) => i === 0 || segment !== '');
501
+ }
502
+
503
+ export function beneathRoot(path: string, root: string): string | undefined {
504
+ const segments = realSegments(path);
505
+ const top = realSegments(root);
506
+ return segments.length > top.length && beginsWith(segments, top) ? segments.slice(top.length).join('/') : undefined;
507
+ }
508
+
509
+ export function sameRoot(path: string, root: string): boolean {
510
+ const segments = realSegments(path);
511
+ const top = realSegments(root);
512
+ return segments.length === top.length && beginsWith(segments, top);
513
+ }
514
+
515
+ export function surelyAtRoot(path: string, root: string): boolean {
516
+ return surelyBeneath(realSegments(path), realSegments(root));
517
+ }
518
+
519
+ export function nestedRootAtReal(real: string, nested: readonly NestedMount[] = []): { mount: NestedMount; depth: number } | undefined {
520
+ return nestedRootsAtReal(real, nested)[0];
521
+ }
522
+
523
+ function nestedRootsAtReal(real: string, nested: readonly NestedMount[]): Array<{ mount: NestedMount; depth: number }> {
524
+ // realpath keeps the spelling it's given where disk folds it on Linux, as
525
+ // from a link's target, so a real root is compared as loosely as a path.
526
+ const segments = realSegments(real);
527
+ const holders = nested.flatMap((n) => {
528
+ if (n.real === undefined) return [];
529
+ const root = realSegments(n.real);
530
+ return beginsWith(segments, root) ? [{ mount: n, depth: root.length, sure: surelyBeneath(segments, root) }] : [];
531
+ });
532
+ return mayHold(holders, (h) => h.depth, (h) => h.sure);
451
533
  }
452
534
 
453
535
  /**
454
536
  * The nested mount whose root holds `real`, a canonical path, the innermost,
455
- * with the path to use there, the path beneath its root (`rest`) and its
456
- * include; undefined when none does. A path the lexical checks pass
537
+ * with the path to use there and the path beneath its root (`rest`);
538
+ * undefined when none does. A path the lexical checks pass
457
539
  * (inOtherMount, the tools' holderOf) reaches another mount's directory this
458
540
  * way through a symlink the mount follows, so each check that resolves a
459
- * path canonically asks this too, and names the mount only where
460
- * namesNestedPlace says the place is that mount's.
541
+ * path canonically asks this too.
461
542
  */
462
- export function nestedAtReal(real: string, nested: readonly NestedMount[] = []): { name: string; use: string; rest: string; include?: readonly string[] } | undefined {
463
- const holder = nested
464
- .flatMap((n) => (n.real !== undefined && contained(n.real, real) ? [{ name: n.name, real: n.real, include: n.include }] : []))
465
- .sort((a, b) => b.real.length - a.real.length)[0];
543
+ export async function nestedAtReal(real: string, nested: readonly NestedMount[] = [], at?: { name: string; rootReal: string }): Promise<{ name: string; use: string; rest: string; judgedByInclude: boolean; names: (directory: boolean) => boolean; guess?: NestedGuess } | undefined> {
544
+ const holders = nestedRootsAtReal(real, nested);
545
+ const holder = holders[0];
466
546
  if (!holder) return undefined;
467
- const rest = relative(holder.real, real).split(sep).join('/');
468
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, rest, include: holder.include };
547
+ const segments = realSegments(real);
548
+ // realpath keeps a link target's spelling beneath the root too, so the
549
+ // rest is read as disk spells it, for each mount's include to judge.
550
+ const rests = await Promise.all(holders.map(async (h) => {
551
+ const given = segments.slice(h.depth).join('/');
552
+ return given === '' ? '' : await diskSpelling(h.mount.real!, given);
553
+ }));
554
+ const rest = rests[0]!;
555
+ const { name } = holder.mount;
556
+ const root = realSegments(holder.mount.real!);
557
+ const found = {
558
+ name,
559
+ use: rest ? `${name}/${rest}` : name,
560
+ rest,
561
+ judgedByInclude: holders.some((h) => h.mount.include !== undefined),
562
+ names: (directory: boolean) => holders.every((h, i) => namesNestedPlace(rests[i]!, h.mount.include, directory)),
563
+ };
564
+ if (surelyBeneath(segments, root)) return found;
565
+ if (at === undefined) return { ...found, guess: null };
566
+ const top = realSegments(at.rootReal);
567
+ if (segments.length <= top.length || root.length <= top.length || !top.every((t, i) => segments[i] === t)) return { ...found, guess: null };
568
+ const within = segments.slice(top.length);
569
+ const below = root.slice(top.length);
570
+ const guess = guessFor(at.name, within, below);
571
+ const leads = guess === undefined ? [at.name, ...within].join('/') : [guess.given, ...within.slice(below.length)].join('/');
572
+ if (!surelyBeneath(root.slice(0, top.length), top)) return { ...found, guess: { ...placedGuess(at.name, within.slice(0, below.length), guess), leads } };
573
+ return { ...found, guess: guess === undefined ? null : { ...guess, leads } };
469
574
  }
470
575
 
471
576
  /**
@@ -473,8 +578,9 @@ export function nestedAtReal(real: string, nested: readonly NestedMount[] = []):
473
578
  * refused (nestedAtReal): what it leads into, and the path to use in its
474
579
  * place. Spelled as the tools take paths, from the mount's name.
475
580
  */
476
- export function leadsInto(name: string, rel: string, holder: { name: string; use: string }): string {
477
- return `"${rel === '' ? name : `${name}/${rel}`}" leads into mount "${holder.name}", nested in mount "${name}": use "${holder.use}" in its place`;
581
+ export function leadsInto(name: string, rel: string, holder: { name: string; use: string; guess?: NestedGuess }): string {
582
+ const verb = holder.guess === undefined ? 'leads' : 'may lead';
583
+ return `"${rel === '' ? name : `${name}/${rel}`}" ${verb} into mount "${holder.name}", nested in mount "${name}": ${nestedStep(holder, { after: ' in its place' })}`;
478
584
  }
479
585
 
480
586
  // ===========================================================================
@@ -488,9 +594,8 @@ const otherCase = (c: string): string => (c === c.toLowerCase() ? c.toUpperCase(
488
594
 
489
595
  /**
490
596
  * The name with each letter's case flipped, where its other case is one
491
- * letter whose own other case is the letter again, so the flip changes only
492
- * pairs that any folding disk folds together. `ß`, whose upper case is `SS`,
493
- * and dotless `ı`, whose upper case `I` lowers to `i`, stay as they are.
597
+ * letter whose own other case is the letter again. `ß`, whose upper case is
598
+ * `SS`, and dotless `ı`, whose upper case `I` lowers to `i`, stay as they are.
494
599
  */
495
600
  const flippedCase = (name: string): string =>
496
601
  [...name].map((c) => {
@@ -509,6 +614,17 @@ const folded = (name: string): string => name.normalize('NFC').toLowerCase();
509
614
  */
510
615
  export const foldedLoosely = (name: string): string => name.normalize('NFC').toLowerCase().toUpperCase().toLowerCase();
511
616
 
617
+ /**
618
+ * Linux's casefold, which ext4 and f2fs set per directory, drops
619
+ * default-ignorable code points (on every kernel but 6.12 to 6.12.4) and
620
+ * compares names decomposed, in full case folding, and HFS+ passes over some
621
+ * of those code points too. Case mapped on the decomposed name joins marks
622
+ * that come in another order around a combining ypogegrammeni (U+0345),
623
+ * whose upper case is a letter.
624
+ */
625
+ const foldedFully = (name: string): string =>
626
+ name.replace(/\p{Default_Ignorable_Code_Point}/gu, '').normalize('NFD').toLowerCase().toUpperCase().toLowerCase().normalize('NFD');
627
+
512
628
  /** What the workspace holds of a mount, as the spelling step reads it (diskSpelling). */
513
629
  export interface WorkspaceEntries {
514
630
  /**
@@ -616,18 +732,81 @@ function entryName(paths: readonly string[], prefix: string, segment: string): s
616
732
  return found;
617
733
  }
618
734
 
619
- /**
620
- * Whether a path's segments begin with a directory's: exactly, or, with
621
- * `loosely`, with each segment compared as loosely as listedName compares
622
- * names (foldedLoosely), as a disk that folds names may compare them. That
623
- * errs toward the directory: on a disk that tells some of those spellings
624
- * apart, such as case, or `ß` from `ss`, a distinct directory spelled one of
625
- * them matches too, and resolveAbsolutePath, the one caller that compares
626
- * loosely, gives null for a path beneath it.
627
- */
628
- export function beginsWithDir(segments: readonly string[], dir: readonly string[], loosely: boolean): boolean {
629
- if (segments.length < dir.length) return false;
630
- return dir.every((d, i) => segments[i] === d || (loosely && foldedLoosely(segments[i]!) === foldedLoosely(d)));
735
+ function sameLevel(given: string, name: string): boolean {
736
+ return given === name || (given !== '' && foldedFully(given) === foldedFully(name));
737
+ }
738
+
739
+ function beginsWith(segments: readonly string[], dir: readonly string[]): boolean {
740
+ return segments.length >= dir.length && dir.every((d, i) => sameLevel(segments[i]!, d));
741
+ }
742
+
743
+ export function beneathNested(segments: readonly string[], nested: NestedDir): boolean {
744
+ return beginsWith(segments, nested.dir.split('/'));
745
+ }
746
+
747
+ export function restBeneath(path: string, nested: NestedDir): string {
748
+ // By segments, since a name that matches can be another length, as `SS`
749
+ // is beside `ß`.
750
+ return path.split('/').slice(nested.dir.split('/').length).join('/');
751
+ }
752
+
753
+ function surelyBeneath(segments: readonly string[], dir: readonly string[]): boolean {
754
+ return segments.length >= dir.length && dir.every((d, i) => segments[i] === d);
755
+ }
756
+
757
+ function shownByCodePoints(name: string): string {
758
+ return [...name].map((c) => {
759
+ const code = c.codePointAt(0)!;
760
+ if (c === '\\') return '\\\\';
761
+ return code < 0x80 ? c : code > 0xffff ? `\\u{${code.toString(16)}}` : `\\u${code.toString(16).padStart(4, '0')}`;
762
+ }).join('');
763
+ }
764
+
765
+ export type NestedGuess = { given: string; dir: string; leads?: string } | { placed: string; dir?: string; leads?: string } | null;
766
+
767
+ function guessFor(mount: string, given: readonly string[], dir: readonly string[]): Extract<NestedGuess, { given: string }> | undefined {
768
+ if (surelyBeneath(given, dir)) return undefined;
769
+ const levels = given.slice(0, dir.length);
770
+ const plain = (a: string, b: string) => a === b || !/[^\x00-\x7f]/.test(a + b);
771
+ const shown = (name: string, other: string) => (plain(name, other) ? name : shownByCodePoints(name));
772
+ return {
773
+ given: [mount, ...levels.map((g, i) => shown(g, dir[i]!))].join('/'),
774
+ dir: [mount, ...dir.map((d, i) => shown(d, levels[i]!))].join('/'),
775
+ };
776
+ }
777
+
778
+ export function nestedGuess(mount: string, segments: readonly string[], nested: NestedDir): NestedGuess | undefined {
779
+ const dir = nested.dir.split('/');
780
+ const guess = guessFor(mount, segments, dir);
781
+ return nested.guessed ? placedGuess(mount, segments.slice(0, dir.length), guess) : guess;
782
+ }
783
+
784
+ function placedGuess(mount: string, levels: readonly string[], guess: ReturnType<typeof guessFor>): Extract<NestedGuess, { placed: string }> {
785
+ return guess === undefined ? { placed: [mount, ...levels].join('/') } : { placed: guess.given, dir: guess.dir };
786
+ }
787
+
788
+ export function nestedStep(holder: { name: string; use: string; guess?: NestedGuess }, opts: { after?: string; named?: boolean } = {}): string {
789
+ const { guess } = holder;
790
+ const after = opts.after ?? '';
791
+ if (guess === undefined) return `use "${holder.use}"${after}`;
792
+ if (guess === null) {
793
+ return `this workspace can't tell whether where it leads is that mount's directory. ` +
794
+ `If you meant that mount's directory, use "${holder.use}"${after}; if not, ask whoever runs this workspace`;
795
+ }
796
+ const leads = guess.leads === undefined ? '' : `it leads to "${guess.leads}", and `;
797
+ if ('placed' in guess) {
798
+ const check = 'ask whoever runs this workspace to check where the two mounts are configured';
799
+ const outside = guess.dir === undefined ? `"${guess.placed}"` : `"${guess.placed}" and "${guess.dir}"`;
800
+ return `${leads}this workspace can't tell whether "${guess.placed}" is mount "${holder.name}"'s directory, since the two mounts' roots are configured in ` +
801
+ `spellings it can't tell apart. If you meant that mount's directory, use "${holder.use}"${after}. ` +
802
+ (opts.named ? `If you meant one of this mount's own, for a new file use a path outside ${outside}, and for one already there ${check}` : `If not, ${check}`);
803
+ }
804
+ const remedy = `rename one of "${guess.given}" and "${guess.dir}"`;
805
+ const own = opts.named
806
+ ? `If you meant one of this mount's own, for a new file use a path outside "${guess.given}" and "${guess.dir}", and for one already there ask whoever runs this workspace to ${remedy}`
807
+ : `If not, ask whoever runs this workspace to ${remedy}`;
808
+ return `${leads}this workspace can't tell "${guess.given}" from "${guess.dir}", ` +
809
+ `mount "${holder.name}"'s directory. If you meant that mount's directory, use "${holder.use}"${after}. ${own}`;
631
810
  }
632
811
 
633
812
  /**
@@ -825,8 +1004,8 @@ export type DirCheck =
825
1004
  * holds the non-directory in its place.
826
1005
  */
827
1006
  | { kind: 'absent'; by: string }
828
- /** Unproven either way; `outside` when it resolves outside the mount. */
829
- | { kind: 'unobserved'; reason: string; outside?: true };
1007
+ /** Unproven either way; `outside` when it resolves outside the mount, `errno` when a system call failed. */
1008
+ | ({ kind: 'unobserved'; outside?: true } & NotShown);
830
1009
 
831
1010
  /** Errors meaning a directory can't be synced on this filesystem, not that syncing it failed. */
832
1011
  const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
@@ -846,11 +1025,16 @@ export class CheckedDir {
846
1025
  private readonly lexical: string,
847
1026
  private readonly dev: bigint,
848
1027
  private readonly ino: bigint,
1028
+ private readonly born: bigint,
849
1029
  ) {}
850
1030
 
851
1031
  /** Which directory it is. */
852
1032
  identity(): RootIdentity {
853
- return { dev: String(this.dev), ino: String(this.ino) };
1033
+ return {
1034
+ dev: String(this.dev),
1035
+ ino: String(this.ino),
1036
+ ...(this.born > 0n ? { born: String(this.born) } : {}),
1037
+ };
854
1038
  }
855
1039
 
856
1040
  /** A path that reaches `name` in this directory. */
@@ -892,11 +1076,25 @@ export class CheckedDir {
892
1076
  }
893
1077
  }
894
1078
 
1079
+ /**
1080
+ * What is at a mount root's path now, followed as the root is: for a
1081
+ * directory, undefined; for anything else, ENOTDIR; and where the path
1082
+ * can't be followed to anything, the errno its stat failed with, ENOENT
1083
+ * when nothing is there.
1084
+ */
1085
+ export async function rootAbsence(root: string): Promise<string | undefined> {
1086
+ try {
1087
+ return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
1088
+ } catch (err) {
1089
+ return errno(err);
1090
+ }
1091
+ }
1092
+
895
1093
  /** Check the directory at a canonical location, if one is there. */
896
1094
  async function checkedAt(view: MountView, rel: string, real: string): Promise<CheckedDir | null> {
897
1095
  const info = await lstat(real, { bigint: true }).catch(() => null);
898
1096
  if (!info?.isDirectory()) return null;
899
- return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino);
1097
+ return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs);
900
1098
  }
901
1099
 
902
1100
  /**
@@ -910,17 +1108,17 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
910
1108
  real = await realpath(lexical);
911
1109
  } catch (err) {
912
1110
  const code = errno(err);
913
- if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
1111
+ if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE, errno: code } : { kind: 'missing' };
914
1112
  // A link on the way that can't be resolved, such as a loop, is still a
915
1113
  // link this mount doesn't follow.
916
1114
  const found = rel === '' ? null : await linkOnTheWay(view, rel);
917
- if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
918
- return { kind: 'unobserved', reason: `cannot resolve the directory (${code ?? String(err)})` };
1115
+ if (found !== null) return { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1116
+ return { kind: 'unobserved', ...failedOn('cannot resolve the directory', err) };
919
1117
  }
920
1118
  // A mount that doesn't follow symlinks goes through none on the way to a
921
1119
  // directory, inside the mount or out of it.
922
1120
  const found = await linkRefusal(view, rootReal, rel, real);
923
- if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1121
+ if (found !== null) return { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
924
1122
  if (rel === '') {
925
1123
  // The root is whatever the configured path names, and it must still be
926
1124
  // the root the pass was given.
@@ -932,10 +1130,10 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
932
1130
  // mount's, which the mount neither reads nor writes. A place there that
933
1131
  // mount's include leaves out is no mount's, and holds nothing beneath it,
934
1132
  // as at any file (namesNestedPlace).
935
- const holder = nestedAtReal(real, view.nested);
1133
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
936
1134
  if (holder) {
937
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
938
- return namesNestedPlace(holder.rest, holder.include, directory) ? { kind: 'unobserved', reason: leadsInto(view.name, rel, holder) } : { kind: 'missing' };
1135
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
1136
+ return holder.names(directory) ? { kind: 'unobserved', reason: leadsInto(view.name, rel, holder), ownStep: true } : { kind: 'missing' };
939
1137
  }
940
1138
  }
941
1139
  const checked = await checkedAt(view, rel, real);
@@ -955,16 +1153,19 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
955
1153
  * The mount root as it is now: its identity if it is available (a directory,
956
1154
  * the one the pass was given, and the one disk last agreed under), else why not.
957
1155
  */
958
- export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string }> {
1156
+ export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string; errno?: string }> {
959
1157
  const found = await checkRoot(view, rootReal);
960
1158
  return 'dir' in found ? { identity: found.dir.identity() } : found;
961
1159
  }
962
1160
 
963
1161
  /** The mount root as locateRoot finds it, checked, so a caller can ask later whether it's still there. */
964
- export async function checkRoot(view: MountView, rootReal: string): Promise<{ dir: CheckedDir } | { reason: string }> {
1162
+ export async function checkRoot(view: MountView, rootReal: string): Promise<{ dir: CheckedDir } | { reason: string; errno?: string }> {
965
1163
  const found = await locateDir(view, rootReal, '');
966
1164
  if (found.kind === 'checked') return { dir: found.dir };
967
- return { reason: found.kind === 'unobserved' ? found.reason : ROOT_UNAVAILABLE };
1165
+ const reason = found.kind === 'unobserved' ? found.reason : ROOT_UNAVAILABLE;
1166
+ // What is at the root's path now says whether the directory is gone from it.
1167
+ const code = found.kind === 'unobserved' && found.errno !== undefined ? found.errno : reason === ROOT_UNAVAILABLE ? await rootAbsence(view.root) : undefined;
1168
+ return { reason, ...(code !== undefined ? { errno: code } : {}) };
968
1169
  }
969
1170
 
970
1171
  /**
@@ -979,7 +1180,7 @@ export async function checkDir(view: MountView, rootReal: string, rel: string, o
979
1180
  if (found.kind !== 'missing') return found;
980
1181
  if (opts.create) return createDir(view, rootReal, rel);
981
1182
  const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
982
- return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
1183
+ return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent', by: proof.by };
983
1184
  }
984
1185
 
985
1186
  async function createDir(view: MountView, rootReal: string, rel: string): Promise<DirCheck> {
@@ -999,13 +1200,13 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
999
1200
  const childRel = parts.slice(0, i + 1).join('/');
1000
1201
  // Nothing is made in a nested mount's directory, its root included where
1001
1202
  // it isn't made yet, by a path that reaches it through a link.
1002
- const holder = nestedAtReal(base.at(parts[i]!), view.nested);
1003
- if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder) };
1203
+ const holder = await nestedAtReal(base.at(parts[i]!), view.nested, { name: view.name, rootReal });
1204
+ if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder), ownStep: true };
1004
1205
  try {
1005
1206
  await mkdir(base.at(parts[i]!));
1006
1207
  } catch (err) {
1007
1208
  const code = errno(err);
1008
- if (code !== 'EEXIST') return { kind: 'unobserved', reason: `cannot create the directory ${childRel} (${code ?? String(err)})` };
1209
+ if (code !== 'EEXIST') return { kind: 'unobserved', ...failedOn(`cannot create the directory ${childRel}`, err) };
1009
1210
  }
1010
1211
  // Made, or there already (made meanwhile, or not a directory): it must
1011
1212
  // be a real directory now, never a symlink or file in its place.
@@ -1014,8 +1215,8 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
1014
1215
  // A symlink in the way, such as one that leads nowhere, on a mount that
1015
1216
  // doesn't follow symlinks, is named as one.
1016
1217
  const there = view.followSymlinks ? null : await lstat(base.at(parts[i]!)).catch(() => null);
1017
- if (there?.isSymbolicLink()) return { kind: 'unobserved', reason: await linkReason(view, rootReal, { link: childRel }) };
1018
- return { kind: 'unobserved', reason: 'a parent path is not a directory' };
1218
+ if (there?.isSymbolicLink()) return { kind: 'unobserved', ...(await linkReason(view, rootReal, { link: childRel })) };
1219
+ return { kind: 'unobserved', reason: NOT_A_DIRECTORY };
1019
1220
  }
1020
1221
  base = checked;
1021
1222
  }
@@ -1037,14 +1238,14 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
1037
1238
  * Returns the directory whose entries prove it, or why absence couldn't be
1038
1239
  * shown.
1039
1240
  */
1040
- export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | string> {
1241
+ export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | NotShown> {
1041
1242
  const root = await locateDir(view, rootReal, '');
1042
- if (root.kind !== 'checked') return ROOT_UNAVAILABLE;
1243
+ if (root.kind !== 'checked') return { reason: ROOT_UNAVAILABLE };
1043
1244
  let cur: CheckedDir = root.dir;
1044
1245
  /** Every directory the walk went through, the root first. */
1045
1246
  const walked: CheckedDir[] = [cur];
1046
- const proven = async (): Promise<{ by: string } | string> => {
1047
- for (const dir of walked) if (!(await dir.stillHere())) return CHANGED;
1247
+ const proven = async (): Promise<{ by: string } | NotShown> => {
1248
+ for (const dir of walked) if (!(await dir.stillHere())) return { reason: CHANGED };
1048
1249
  return { by: cur.rel };
1049
1250
  };
1050
1251
  const parts = rel.split('/');
@@ -1057,41 +1258,44 @@ export async function confirmAbsent(view: MountView, rootReal: string, rel: stri
1057
1258
  info = await lstat(cur.at(name), { bigint: true });
1058
1259
  } catch (err) {
1059
1260
  const code = errno(err);
1060
- if (code === 'ENOTDIR') return CHANGED;
1061
- if (code !== 'ENOENT') return `cannot stat (${code ?? String(err)})`;
1261
+ if (code === 'ENOTDIR') return { reason: CHANGED };
1262
+ if (code !== 'ENOENT') return failedOn('cannot stat', err);
1062
1263
  // Absent from a directory that, like each above it, is still the one its path names.
1063
1264
  return proven();
1064
1265
  }
1065
1266
  if (info.isDirectory()) {
1066
- if (last) return CHANGED; // present after all
1067
- cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino);
1267
+ if (last) return { reason: CHANGED }; // present after all
1268
+ cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, info.birthtimeNs);
1068
1269
  walked.push(cur);
1069
1270
  continue;
1070
1271
  }
1071
1272
  if (info.isSymbolicLink()) {
1072
- if (last && target === 'entry') return CHANGED; // present: the link itself
1273
+ if (last && target === 'entry') return { reason: CHANGED }; // present: the link itself
1073
1274
  // A mount that doesn't follow symlinks goes through none on the way.
1074
1275
  if (!view.followSymlinks) return linkReason(view, rootReal, { link: childRel });
1075
1276
  const located = await locateDir(view, rootReal, childRel);
1076
- if (located.kind !== 'checked') return BENEATH_SYMLINK;
1077
- if (last) return CHANGED; // a directory there after all
1277
+ if (located.kind === 'unobserved' && located.outside !== true) {
1278
+ return { reason: located.reason, ...(located.errno !== undefined ? { errno: located.errno } : {}), ...(located.ownStep === true ? { ownStep: true } : {}) };
1279
+ }
1280
+ if (located.kind !== 'checked') return { reason: BENEATH_SYMLINK };
1281
+ if (last) return { reason: CHANGED }; // a directory there after all
1078
1282
  cur = located.dir;
1079
1283
  walked.push(cur);
1080
1284
  continue;
1081
1285
  }
1082
- if (last && target === 'entry') return CHANGED; // present after all
1286
+ if (last && target === 'entry') return { reason: CHANGED }; // present after all
1083
1287
  // A non-directory: nothing can exist beneath it, while it is still this
1084
1288
  // same one, in the directory still at its path.
1085
1289
  const again = await lstat(cur.at(name), { bigint: true }).catch(() => null);
1086
- if (again === null || again.isDirectory() || again.isSymbolicLink() || again.dev !== info.dev || again.ino !== info.ino) return CHANGED;
1290
+ if (again === null || again.isDirectory() || again.isSymbolicLink() || again.dev !== info.dev || again.ino !== info.ino) return { reason: CHANGED };
1087
1291
  return proven();
1088
1292
  }
1089
- return CHANGED;
1293
+ return { reason: CHANGED };
1090
1294
  }
1091
1295
 
1092
1296
  async function absence(view: MountView, rootReal: string, rel: string): Promise<Exclude<Observation, { kind: 'file' }>> {
1093
1297
  const proof = await confirmAbsent(view, rootReal, rel);
1094
- return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent' };
1298
+ return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent' };
1095
1299
  }
1096
1300
 
1097
1301
  // ===========================================================================
@@ -1167,26 +1371,26 @@ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
1167
1371
  * final component — which on a mount that doesn't follow symlinks is any
1168
1372
  * symlink on the way.
1169
1373
  */
1170
- async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<{ inside: string } | 'gone' | { reason: string }> {
1374
+ async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<{ inside: string } | 'gone' | NotShown> {
1171
1375
  try {
1172
1376
  const parent = await realpath(dirname(join(view.root, relativePath)));
1173
1377
  const found = await linkRefusal(view, rootReal, relativePath, parent, 'parents');
1174
- if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1378
+ if (found !== null) return linkReason(view, rootReal, found);
1175
1379
  if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT };
1176
- const holder = nestedAtReal(parent, view.nested);
1380
+ const holder = await nestedAtReal(parent, view.nested, { name: view.name, rootReal });
1177
1381
  if (!holder) return { inside: parent };
1178
1382
  // A place there that the nested mount's include leaves out is no mount's,
1179
1383
  // and holds nothing beneath it, as beneath any file (namesNestedPlace).
1180
- const directory = holder.include !== undefined && await stat(parent).then((info) => info.isDirectory(), () => false);
1181
- return namesNestedPlace(holder.rest, holder.include, directory) ? { reason: leadsInto(view.name, parentOf(relativePath), holder) } : 'gone';
1384
+ const directory = holder.judgedByInclude && await stat(parent).then((info) => info.isDirectory(), () => false);
1385
+ return holder.names(directory) ? { reason: leadsInto(view.name, parentOf(relativePath), holder), ownStep: true } : 'gone';
1182
1386
  } catch (err) {
1183
1387
  const code = errno(err);
1184
1388
  if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
1185
1389
  // A link on the way that can't be resolved, such as a loop, is still a
1186
1390
  // link this mount doesn't follow.
1187
1391
  const found = await linkOnTheWay(view, relativePath, 'parents');
1188
- if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1189
- return { reason: `cannot resolve the parent directory (${code ?? String(err)})` };
1392
+ if (found !== null) return linkReason(view, rootReal, found);
1393
+ return failedOn('cannot resolve the parent directory', err);
1190
1394
  }
1191
1395
  }
1192
1396
 
@@ -1200,14 +1404,14 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1200
1404
  const lexical = join(view.root, relativePath);
1201
1405
  const placement = await parentPlacement(view, rootReal, relativePath);
1202
1406
  if (placement === 'gone') return absence(view, rootReal, relativePath);
1203
- if ('reason' in placement) return { kind: 'unobserved', reason: placement.reason };
1407
+ if ('reason' in placement) return { kind: 'unobserved', ...placement };
1204
1408
  let info;
1205
1409
  try {
1206
1410
  info = await lstat(lexical, { bigint: true });
1207
1411
  } catch (err) {
1208
1412
  const code = errno(err);
1209
1413
  if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
1210
- return { kind: 'unobserved', reason: `cannot stat (${code ?? String(err)})` };
1414
+ return { kind: 'unobserved', ...failedOn('cannot stat', err) };
1211
1415
  }
1212
1416
  if (info.isSymbolicLink()) {
1213
1417
  if (!view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
@@ -1215,37 +1419,37 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1215
1419
  try {
1216
1420
  target = await stat(lexical, { bigint: true });
1217
1421
  } catch (err) {
1218
- return { kind: 'unobserved', reason: `a symlink whose target cannot be read (${errno(err) ?? String(err)})` };
1422
+ return { kind: 'unobserved', ...failedOn('a symlink whose target cannot be read', err) };
1219
1423
  }
1220
1424
  let real: string;
1221
1425
  try {
1222
1426
  real = await realpath(lexical);
1223
1427
  } catch (err) {
1224
- return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
1428
+ return { kind: 'unobserved', ...failedOn('a symlink that cannot be resolved', err) };
1225
1429
  }
1226
- if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1227
- const holder = nestedAtReal(real, view.nested);
1430
+ if (!contained(rootReal, real)) return { kind: 'unobserved', reason: LEAVES_MOUNT };
1431
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1228
1432
  // A place a nested mount's include leaves out is no mount's, so the link
1229
1433
  // leaves this one (namesNestedPlace).
1230
1434
  if (holder) {
1231
- return { kind: 'unobserved', reason: namesNestedPlace(holder.rest, holder.include, target.isDirectory()) ? leadsInto(view.name, relativePath, holder) : 'a symlink that leaves the mount' };
1435
+ return holder.names(target.isDirectory()) ? { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder), ownStep: true } : { kind: 'unobserved', reason: LEAVES_MOUNT };
1232
1436
  }
1233
1437
  if (!target.isFile()) {
1234
- return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
1438
+ return { kind: 'unobserved', reason: target.isDirectory() ? LINK_TO_DIRECTORY : NOT_REGULAR };
1235
1439
  }
1236
1440
  // A file this mount's own include leaves out where the link lands is no
1237
1441
  // file of this mount's (coversLanding). It's judged here, and not only
1238
1442
  // where the file is opened, since a pass takes a copy whose fingerprint
1239
1443
  // the stat vouches for without opening it.
1240
- if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1444
+ if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: LEAVES_MOUNT };
1241
1445
  info = target;
1242
1446
  } else if (info.isDirectory()) {
1243
- return { kind: 'unobserved', reason: 'a directory' };
1447
+ return { kind: 'unobserved', reason: DIRECTORY };
1244
1448
  } else if (!info.isFile()) {
1245
- return { kind: 'unobserved', reason: 'not a regular file' };
1449
+ return { kind: 'unobserved', reason: NOT_REGULAR };
1246
1450
  } else if (!(await coversLanding(view, rootReal, join(placement.inside, basename(lexical)), relativePath))) {
1247
1451
  // So is one reached through a link among its directories.
1248
- return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1452
+ return { kind: 'unobserved', reason: LEAVES_MOUNT };
1249
1453
  }
1250
1454
  return { kind: 'file', size: Number(info.size), mtimeMs: Number(info.mtimeMs), mode: Number(info.mode & 0o777n), fp: statFingerprint(info) };
1251
1455
  }
@@ -1280,35 +1484,35 @@ async function openContained(
1280
1484
  if (code === 'ELOOP' && !view.followSymlinks) {
1281
1485
  // The link at the path, or a looping one on its way.
1282
1486
  const found = await linkOnTheWay(view, relativePath, 'parents');
1283
- return { kind: 'unobserved', reason: found === null ? NOT_FOLLOWED : await linkReason(view, rootReal, found) };
1487
+ return found === null ? { kind: 'unobserved', reason: NOT_FOLLOWED } : { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1284
1488
  }
1285
- if (code === 'EISDIR') return { kind: 'unobserved', reason: 'a directory' };
1286
- return { kind: 'unobserved', reason: `cannot open (${code ?? String(err)})` };
1489
+ if (code === 'EISDIR') return { kind: 'unobserved', reason: DIRECTORY };
1490
+ return { kind: 'unobserved', ...failedOn('cannot open', err) };
1287
1491
  }
1288
1492
  let keep = false;
1289
1493
  try {
1290
1494
  const info = await handle.stat({ bigint: true });
1291
1495
  const real = await realpath(lexical).catch(() => null);
1292
- if (real === null) return { kind: 'unobserved', reason: 'the file changed while it was read' };
1496
+ if (real === null) return { kind: 'unobserved', reason: FILE_CHANGED };
1293
1497
  // O_NOFOLLOW guards only the final component: a mount that doesn't
1294
1498
  // follow symlinks goes through none among the directories on the way
1295
1499
  // either. Asked of what the path resolved to after the open, which the
1296
1500
  // check below holds to the descriptor's file, and before what the
1297
1501
  // descriptor holds, so a link gets its own answer whatever it leads to.
1298
1502
  const found = await linkRefusal(view, rootReal, relativePath, real);
1299
- if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1300
- if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? 'a directory' : 'not a regular file' };
1301
- const leaves = view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT;
1503
+ if (found !== null) return { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1504
+ if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? DIRECTORY : NOT_REGULAR };
1505
+ const leaves = view.followSymlinks ? LEAVES_MOUNT : OUTSIDE_PARENT;
1302
1506
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: leaves };
1303
- const holder = nestedAtReal(real, view.nested);
1507
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1304
1508
  // A place a nested mount's include leaves out is no mount's. Reached
1305
1509
  // through a link this mount doesn't follow, the link is what's refused.
1306
- if (holder) return { kind: 'unobserved', reason: namesNestedPlace(holder.rest, holder.include, false) ? leadsInto(view.name, relativePath, holder) : view.followSymlinks ? leaves : NOT_FOLLOWED };
1510
+ if (holder) return holder.names(false) ? { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder), ownStep: true } : { kind: 'unobserved', reason: view.followSymlinks ? leaves : NOT_FOLLOWED };
1307
1511
  // So is a file this mount's own include leaves out (coversLanding).
1308
1512
  if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: view.followSymlinks ? leaves : NOT_FOLLOWED };
1309
1513
  const named = await stat(real, { bigint: true }).catch(() => null);
1310
1514
  if (named === null || named.dev !== info.dev || named.ino !== info.ino) {
1311
- return { kind: 'unobserved', reason: 'the file changed while it was read' };
1515
+ return { kind: 'unobserved', reason: FILE_CHANGED };
1312
1516
  }
1313
1517
  if (!view.followSymlinks) {
1314
1518
  // Where no symlink is followed, the path itself must be the file the
@@ -1317,7 +1521,7 @@ async function openContained(
1317
1521
  const entry = await lstat(lexical, { bigint: true }).catch(() => null);
1318
1522
  if (entry?.isSymbolicLink()) return { kind: 'unobserved', reason: NOT_FOLLOWED };
1319
1523
  if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) {
1320
- return { kind: 'unobserved', reason: 'the file changed while it was read' };
1524
+ return { kind: 'unobserved', reason: FILE_CHANGED };
1321
1525
  }
1322
1526
  }
1323
1527
  keep = true;
@@ -1325,7 +1529,7 @@ async function openContained(
1325
1529
  } catch (err) {
1326
1530
  const code = errno(err);
1327
1531
  if (code === 'ENOENT') return absence(view, rootReal, relativePath);
1328
- return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
1532
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1329
1533
  } finally {
1330
1534
  if (!keep) await handle.close();
1331
1535
  }
@@ -1423,7 +1627,7 @@ export async function readPath(
1423
1627
  } catch (err) {
1424
1628
  const code = errno(err);
1425
1629
  if (code === 'ENOENT') return absence(view, rootReal, relativePath);
1426
- return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
1630
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1427
1631
  } finally {
1428
1632
  await handle.close();
1429
1633
  }
@@ -1483,7 +1687,7 @@ export async function readBytes(
1483
1687
  if (more) return { kind: 'bytes', size: Math.max(Number((await handle.stat({ bigint: true })).size), bytes.length + 1), bytes: null, head: bytes.subarray(0, HEAD_BYTES) };
1484
1688
  return { kind: 'bytes', size: bytes.byteLength, bytes, head: bytes.subarray(0, HEAD_BYTES) };
1485
1689
  } catch (err) {
1486
- return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
1690
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1487
1691
  } finally {
1488
1692
  await handle.close();
1489
1693
  }
@@ -1507,7 +1711,7 @@ export async function readHead(
1507
1711
  const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
1508
1712
  return { kind: 'head', size: Number(info.size), mtimeMs: Number(info.mtimeMs), head: head.subarray(0, bytesRead) };
1509
1713
  } catch (err) {
1510
- return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
1714
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1511
1715
  } finally {
1512
1716
  await handle.close();
1513
1717
  }
@@ -1579,7 +1783,7 @@ export interface Walk {
1579
1783
  * `symlink` region is never walked; the pass reports the link itself by
1580
1784
  * path.
1581
1785
  */
1582
- incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink' }>;
1786
+ incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink'; errno?: string }>;
1583
1787
  /**
1584
1788
  * When the walk cap stopped the walk, the last entry it took. A walk that
1585
1789
  * resumes after it takes the entries after it first, in the walk's order
@@ -1636,14 +1840,14 @@ function subtreeAgainst(dir: string, at: string): 'before' | 'holds' | 'after' {
1636
1840
  }
1637
1841
 
1638
1842
  /**
1639
- * Whether a mount's ignore list covers a mount-relative path: the path itself
1640
- * or any directory above it, as the walk's own test reads each entry by its
1641
- * path and name.
1843
+ * Whether a mount's ignore list covers a mount-relative path: the path itself,
1844
+ * a directory or not, or any directory above it, as the walk's own test reads
1845
+ * each entry by its path and kind.
1642
1846
  */
1643
- export function coveredByIgnore(relativePath: string, patterns: string[]): boolean {
1847
+ export function coveredByIgnore(relativePath: string, patterns: readonly string[], directory: boolean): boolean {
1644
1848
  const segments = relativePath.split('/').filter(Boolean);
1645
1849
  for (let i = 0; i < segments.length; i++) {
1646
- if (isIgnored(segments.slice(0, i + 1).join('/'), segments[i]!, patterns)) return true;
1850
+ if (isIgnored(segments.slice(0, i + 1).join('/'), patterns, i < segments.length - 1 || directory)) return true;
1647
1851
  }
1648
1852
  return false;
1649
1853
  }
@@ -1688,42 +1892,89 @@ export function insideMount(relativePath: string, include: readonly string[] | u
1688
1892
  }
1689
1893
 
1690
1894
  /**
1691
- * Whether the include's matcher can read `pattern`: a name or an `*.ext` glob,
1692
- * which covers a file by its own name, or a `dir/**` glob, with no empty, `.`
1693
- * or `..` segment, since those name no entry a path reaches. Where `\` is the
1694
- * platform's separator, as on Windows, a pattern holding one can't name a
1695
- * file either, since every path is read with `\` as a separator
1696
- * (pathSegments). A pattern the matcher can't read would match nothing, and
1697
- * take every file out of the mount unsaid, so the module refuses it.
1895
+ * A pattern of `ignore` or `include`, read as gitignore reads it: its
1896
+ * segments, each a name or a glob with `*` and `?` within it; whether it's a
1897
+ * path from the mount's root (a leading or inner `/`, or a trailing `/**`)
1898
+ * rather than a name at any depth; and whether it matches only directories (a
1899
+ * trailing `/` or `/**`).
1698
1900
  */
1699
- export function includePatternReadable(pattern: unknown, platformSep: string = sep): boolean {
1700
- const segment = (s: string): boolean => s.length > 0 && s !== '.' && s !== '..' && !(platformSep === '\\' && s.includes('\\'));
1701
- const name = (s: string): boolean => segment(s) && !s.includes('/') &&
1702
- (!s.includes('*') || (s.startsWith('*.') && s.length > 2 && !s.slice(1).includes('*')));
1703
- return typeof pattern === 'string' &&
1704
- (pattern.endsWith('/**') ? pattern.length > 3 && pattern.slice(0, -3).split('/').every((s) => segment(s) && !s.includes('*')) : name(pattern));
1901
+ type Pattern = { segments: Array<string | RegExp>; anchored: boolean; directory: boolean };
1902
+
1903
+ const readings = new Map<string, Pattern | string>();
1904
+
1905
+ function readPattern(pattern: string): Pattern | string {
1906
+ let reading = readings.get(pattern);
1907
+ if (reading === undefined) {
1908
+ reading = parsePattern(pattern);
1909
+ readings.set(pattern, reading);
1910
+ }
1911
+ return reading;
1912
+ }
1913
+
1914
+ function parsePattern(pattern: string): Pattern | string {
1915
+ if (pattern.startsWith('!')) return 'a leading "!" makes an exception in a .gitignore, and this list has none, so leave it out';
1916
+ if (pattern.startsWith('#')) return 'a leading "#" marks a comment in a .gitignore, so leave comments out of the list';
1917
+ if (pattern.includes('\\')) return '"\\" is an escape in a .gitignore and a separator on Windows, so no pattern here holds one, and "?" stands for one in a name';
1918
+ if (pattern.endsWith(' ')) return 'a .gitignore drops trailing spaces, so remove them';
1919
+ const anyDepth = pattern.startsWith('**/');
1920
+ let body = anyDepth ? pattern.slice(3) : pattern;
1921
+ const beneath = body.endsWith('/**');
1922
+ const directory = beneath || body.endsWith('/');
1923
+ body = body.slice(0, beneath ? -3 : directory ? -1 : undefined);
1924
+ const anchored = (beneath && !anyDepth) || body.includes('/');
1925
+ if (body.startsWith('/') && !anyDepth) body = body.slice(1);
1926
+ const segments = body.split('/');
1927
+ if (segments.some((s) => s === '' || s === '.' || s === '..')) return 'it has an empty, "." or ".." segment, which names no entry';
1928
+ if (body.includes('**') || (anyDepth && anchored)) return '"**" is read only as a leading "**/" before one name, or a trailing "/**"';
1929
+ const glob = (s: string): string | RegExp => /[*?]/.test(s)
1930
+ ? new RegExp(`^${s.replace(/[.+^${}()|[\]*?]/g, (c) => (c === '*' ? '.*' : c === '?' ? '.' : `\\${c}`))}$`, 'su')
1931
+ : s;
1932
+ return { segments: segments.map(glob), anchored, directory };
1933
+ }
1934
+
1935
+ function nameMatches(segment: string | RegExp, name: string): boolean {
1936
+ return typeof segment === 'string' ? segment === name : segment.test(name);
1937
+ }
1938
+
1939
+ function matchesEntry(pattern: Pattern, segments: readonly string[], directory: boolean): boolean {
1940
+ if (pattern.directory && !directory) return false;
1941
+ if (!pattern.anchored) return nameMatches(pattern.segments[0]!, segments[segments.length - 1]!);
1942
+ return segments.length === pattern.segments.length && pattern.segments.every((s, i) => nameMatches(s, segments[i]!));
1943
+ }
1944
+
1945
+ export function patternRefusal(pattern: unknown, list: 'ignore' | 'include'): string | undefined {
1946
+ if (typeof pattern !== 'string') return "it isn't a string";
1947
+ const reading = readPattern(pattern);
1948
+ if (typeof reading === 'string') return reading;
1949
+ if (list === 'ignore' && pattern.includes('[')) {
1950
+ return '"[" starts a character class in a .gitignore, which this list doesn\'t read, so write each name out, with "?" for a "[" in it';
1951
+ }
1952
+ return undefined;
1953
+ }
1954
+
1955
+ export function directoryAt(path: string, followSymlinks: boolean): Promise<boolean | undefined> {
1956
+ if (!followSymlinks) return lstat(path).then((s) => s.isDirectory(), () => undefined);
1957
+ return stat(path).then((s) => s.isDirectory(), () => lstat(path).then(() => false, () => undefined));
1705
1958
  }
1706
1959
 
1707
1960
  /**
1708
- * Whether a mount's include covers a mount-relative path. Its patterns take
1709
- * `ignore`'s forms, read for files: a name or `*.ext` pattern covers a path by
1710
- * its own last segment, at any depth, and `dir/**` covers what lies beneath
1711
- * `dir`, and not a file named `dir`. Unlike `ignore`, a pattern that matches
1712
- * a directory's name covers nothing beneath it: each file is covered by its
1713
- * own name, so `gate.json` covers `x/gate.json` and not `x/gate.json/run.sh`,
1714
- * and a subtree is named with `dir/**`. A directory is no file, so it needs
1715
- * no covering: where one is a scope, refusesAsOutside (index.ts) decides.
1961
+ * Whether a mount's include covers a mount-relative path, read as a file: a
1962
+ * pattern that matches only directories covers what lies beneath a directory
1963
+ * it matches, and any other covers a file by its own name or path, and
1964
+ * nothing beneath a directory it matches. So `gate.json` covers `x/gate.json`
1965
+ * and not `x/gate.json/run.sh`, and a subtree is named with `dir/` or
1966
+ * `dir/**`. A directory is no file, so it needs no covering: where one is a
1967
+ * scope, refusesAsOutside (index.ts) decides.
1716
1968
  */
1717
1969
  function coveredByInclude(relativePath: string, include: readonly string[]): boolean {
1718
- const name = relativePath.slice(relativePath.lastIndexOf('/') + 1);
1719
- for (const pattern of include) {
1720
- if (pattern.endsWith('/**')) {
1721
- if (relativePath.startsWith(pattern.slice(0, -2))) return true;
1722
- } else if (pattern === name || (pattern.startsWith('*.') && name.endsWith(pattern.slice(1)))) {
1723
- return true;
1724
- }
1725
- }
1726
- return false;
1970
+ const segments = relativePath.split('/');
1971
+ return include.some((p) => {
1972
+ const pattern = readPattern(p);
1973
+ if (typeof pattern === 'string') return false;
1974
+ if (!pattern.directory) return matchesEntry(pattern, segments, false);
1975
+ for (let k = 1; k < segments.length; k++) if (matchesEntry(pattern, segments.slice(0, k), true)) return true;
1976
+ return false;
1977
+ });
1727
1978
  }
1728
1979
 
1729
1980
  /**
@@ -1732,7 +1983,7 @@ function coveredByInclude(relativePath: string, include: readonly string[]): boo
1732
1983
  * it (#276); one covering only something inside it does.
1733
1984
  */
1734
1985
  export function ignoreBeneath(scope: string, patterns: string[]): string[] {
1735
- return patterns.filter((pattern) => !coveredByIgnore(scope, [pattern]));
1986
+ return patterns.filter((pattern) => !coveredByIgnore(scope, [pattern], true));
1736
1987
  }
1737
1988
 
1738
1989
  /**
@@ -1753,7 +2004,7 @@ const EDITOR_FILE = /^\..+\.sw[px]$|~$|\.subl.*\.tmp$/;
1753
2004
  /** The walk's reason for an entry the mount's ignore list covers. */
1754
2005
  export const IGNORED = 'ignored by the mount';
1755
2006
  /** The walk's reason for an entry `EDITOR_FILE` names. */
1756
- const EDITOR_FILE_REASON = "an editor's swap or backup file";
2007
+ export const EDITOR_FILE_REASON = "an editor's swap or backup file";
1757
2008
 
1758
2009
  /**
1759
2010
  * The walk's reason for a link it doesn't enter. On a mount with an include,
@@ -1762,7 +2013,7 @@ const EDITOR_FILE_REASON = "an editor's swap or backup file";
1762
2013
  * that follows links, one that leads to a directory, and on one that
1763
2014
  * doesn't, every one.
1764
2015
  */
1765
- const LINK_NOT_WALKED = 'a symlink, not walked';
2016
+ export const LINK_NOT_WALKED = 'a symlink, not walked';
1766
2017
 
1767
2018
  /** Whether a reason the walk gave an entry is one it passes over by design. */
1768
2019
  export function passedOverByDesign(why: string | undefined): boolean {
@@ -1781,34 +2032,34 @@ export const OTHER_MOUNT = "another mount's directory";
1781
2032
  * Every nested-mount check (a pass's, a walk's, a push's, grep's) asks this.
1782
2033
  */
1783
2034
  export function inOtherMount(relativePath: string, others: readonly string[]): boolean {
1784
- const lexical = posix.normalize(relativePath).replace(/\/+$/, '');
1785
- return others.some((dir) => lexical === dir || lexical.startsWith(dir + '/'));
2035
+ const segments = posix.normalize(relativePath).replace(/\/+$/, '').split('/');
2036
+ return others.some((dir) => beneathNested(segments, { dir }));
1786
2037
  }
1787
2038
 
1788
2039
  /**
1789
- * Why a walk passes over an entry by design, by its mount-relative path and
1790
- * its name: another mount's directory, the ignore list, or an editor's file
1791
- * name. Undefined when none covers it.
2040
+ * Why a walk passes over an entry by design, by its mount-relative path, its
2041
+ * name and whether it's a directory: another mount's directory, the ignore
2042
+ * list, or an editor's file name. Undefined when none covers it.
1792
2043
  */
1793
- function passedOverBecause(relativePath: string, name: string, patterns: string[], others: readonly string[]): string | undefined {
2044
+ function passedOverBecause(relativePath: string, name: string, patterns: string[], others: readonly string[], directory: boolean): string | undefined {
1794
2045
  if (inOtherMount(relativePath, others)) return OTHER_MOUNT;
1795
- if (isIgnored(relativePath, name, patterns)) return IGNORED;
2046
+ if (isIgnored(relativePath, patterns, directory)) return IGNORED;
1796
2047
  if (EDITOR_FILE.test(name)) return EDITOR_FILE_REASON;
1797
2048
  return undefined;
1798
2049
  }
1799
2050
 
1800
2051
  /**
1801
2052
  * Why a sync without a path, and the watcher, pass over a mount-relative
1802
- * path: the path itself or a directory above it is passed over by design, as
1803
- * the walk reads each entry by its path and name. A path in another mount's
1804
- * directory is that mount's, whatever else covers a directory above it.
1805
- * Undefined when nothing covers it.
2053
+ * path, a directory or not: the path itself or a directory above it is passed
2054
+ * over by design, as the walk reads each entry by its path, name and kind. A
2055
+ * path in another mount's directory is that mount's, whatever else covers a
2056
+ * directory above it. Undefined when nothing covers it.
1806
2057
  */
1807
- export function passedOverAt(relativePath: string, patterns: string[], others: readonly string[]): string | undefined {
2058
+ export function passedOverAt(relativePath: string, patterns: string[], others: readonly string[], directory: boolean): string | undefined {
1808
2059
  if (inOtherMount(relativePath, others)) return OTHER_MOUNT;
1809
2060
  const segments = relativePath.split('/').filter(Boolean);
1810
2061
  for (let i = 0; i < segments.length; i++) {
1811
- const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others);
2062
+ const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others, i < segments.length - 1 || directory);
1812
2063
  if (why !== undefined) return why;
1813
2064
  }
1814
2065
  return undefined;
@@ -1824,24 +2075,19 @@ export function passedOverBetween(scope: string, relativePath: string, patterns:
1824
2075
  const segments = relativePath.split('/').filter(Boolean);
1825
2076
  const from = scope === '' ? 0 : scope.split('/').filter(Boolean).length;
1826
2077
  for (let i = from; i < segments.length - 1; i++) {
1827
- const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others);
2078
+ const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others, true);
1828
2079
  if (why !== undefined) return why;
1829
2080
  }
1830
2081
  return undefined;
1831
2082
  }
1832
2083
 
1833
- /** Simple ignore patterns, as the watcher and the old walk read them. */
1834
- export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean {
1835
- for (const pattern of patterns) {
1836
- if (pattern === name) return true;
1837
- if (pattern.endsWith('/**')) {
1838
- const prefix = pattern.slice(0, -3);
1839
- if (relativePath === prefix || relativePath.startsWith(prefix + '/')) return true;
1840
- }
1841
- if (!pattern.includes('/') && relativePath === pattern) return true;
1842
- if (pattern.startsWith('*.') && name.endsWith(pattern.slice(1))) return true;
1843
- }
1844
- return false;
2084
+ /** Whether an ignore pattern matches the entry at a mount-relative path, a directory or not, itself. */
2085
+ function isIgnored(relativePath: string, patterns: readonly string[], directory: boolean): boolean {
2086
+ const segments = relativePath.split('/');
2087
+ return patterns.some((p) => {
2088
+ const pattern = readPattern(p);
2089
+ return typeof pattern !== 'string' && matchesEntry(pattern, segments, directory);
2090
+ });
1845
2091
  }
1846
2092
 
1847
2093
  /**
@@ -1855,12 +2101,12 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
1855
2101
  try {
1856
2102
  info = await lstat(real, { bigint: true });
1857
2103
  } catch (err) {
1858
- if (errno(err) !== 'ENOENT') return { kind: 'unobserved', reason: `cannot stat (${errno(err) ?? String(err)})` };
2104
+ if (errno(err) !== 'ENOENT') return { kind: 'unobserved', ...failedOn('cannot stat', err) };
1859
2105
  const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
1860
- return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
2106
+ return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent', by: proof.by };
1861
2107
  }
1862
2108
  if (!info.isDirectory()) return { kind: 'unobserved', reason: CHANGED_WALK };
1863
- return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino) };
2109
+ return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs) };
1864
2110
  }
1865
2111
 
1866
2112
  /**
@@ -1893,12 +2139,11 @@ export async function walkScope(
1893
2139
  };
1894
2140
  // A walk that isn't named passes over a scope the mount passes over by
1895
2141
  // design, at it or a directory above it, as its parent's listing would pass
1896
- // over its entry: a name rule (a bare-name ignore entry, an editor's name)
1897
- // doesn't match what lies beneath, as a path rule (`cache/**`) does. So a
1898
- // listing, glob or grep there never starts tracking what the mount leaves
1899
- // out, and a stored copy beneath it is unobserved, as beneath any region
1900
- // passed over by design. Its region says how to take what's there in: a
1901
- // sync that names it, whose scope is walked (#276), below.
2142
+ // over its entry. So a listing, glob or grep there never starts tracking
2143
+ // what the mount leaves out, and a stored copy beneath it is unobserved, as
2144
+ // beneath any region passed over by design. Its region says how to take
2145
+ // what's there in: a sync that names it, whose scope is walked (#276),
2146
+ // below.
1902
2147
  // Another mount's directory is passed over by every walk, named or not,
1903
2148
  // and no sync of this mount takes it in, so its region gives no advice.
1904
2149
  if (scope !== '' && inOtherMount(scope, view.others)) {
@@ -1907,7 +2152,10 @@ export async function walkScope(
1907
2152
  return walk;
1908
2153
  }
1909
2154
  if (!opts.named && scope !== '') {
1910
- const why = passedOverAt(scope, view.ignore, view.others);
2155
+ let why = passedOverAt(scope, view.ignore, view.others, true);
2156
+ if (why !== undefined && passedOverAt(scope, view.ignore, view.others, false) === undefined) {
2157
+ if ((await directoryAt(join(rootReal, scope), view.followSymlinks)) === false) why = undefined;
2158
+ }
1911
2159
  if (why !== undefined) {
1912
2160
  walk.others.set(scope, why);
1913
2161
  walk.incomplete.push({ path: scope, reason: `${why}: a sync that names this path takes in what's there, editors' files aside`, kind: 'ignored' });
@@ -1920,7 +2168,7 @@ export async function walkScope(
1920
2168
  return walk;
1921
2169
  }
1922
2170
  if (top.kind === 'unobserved') {
1923
- walk.incomplete.push({ path: scope, reason: top.outside ? 'the directory resolves outside the mount' : top.reason, kind: 'error' });
2171
+ walk.incomplete.push({ path: scope, reason: top.outside ? RESOLVES_OUTSIDE : top.reason, kind: 'error', ...(top.errno !== undefined ? { errno: top.errno } : {}) });
1924
2172
  return walk;
1925
2173
  }
1926
2174
  // A scope a path sync names is walked even where the ignore list covers
@@ -1948,10 +2196,10 @@ export async function walkScope(
1948
2196
  const visited = new Set<string>();
1949
2197
  /** Paths already reported as errors, so a second visit doesn't report them again. */
1950
2198
  const failed = new Set<string>();
1951
- const fail = (path: string, reason: string): void => {
2199
+ const fail = (path: string, reason: string, code?: string): void => {
1952
2200
  if (failed.has(path)) return;
1953
2201
  failed.add(path);
1954
- walk.incomplete.push({ path, reason, kind: 'error' });
2202
+ walk.incomplete.push({ path, reason, kind: 'error', ...(code !== undefined ? { errno: code } : {}) });
1955
2203
  };
1956
2204
  let left = cap;
1957
2205
  let lastTaken: string | undefined;
@@ -2014,6 +2262,7 @@ export async function walkScope(
2014
2262
  // Gone since it was checked: absent beneath only if that can be shown.
2015
2263
  const proof = rel === '' ? ROOT_UNAVAILABLE : await confirmAbsent(view, rootReal, rel, 'beneath');
2016
2264
  if (typeof proof === 'string') fail(rel, proof);
2265
+ else if ('reason' in proof) fail(rel, proof.reason, proof.errno);
2017
2266
  else {
2018
2267
  walk.missing.add(rel);
2019
2268
  // Nothing was listed, so a later check that finds it again, on the
@@ -2022,7 +2271,8 @@ export async function walkScope(
2022
2271
  }
2023
2272
  return null;
2024
2273
  }
2025
- fail(rel, `cannot list (${code ?? String(err)})`);
2274
+ const listed = failedOn('cannot list', err);
2275
+ fail(rel, listed.reason, listed.errno);
2026
2276
  return null;
2027
2277
  }
2028
2278
  entries.sort((a, b) => compareNames(a.name, b.name));
@@ -2040,9 +2290,13 @@ export async function walkScope(
2040
2290
  const childRel = rel ? `${rel}/${entry.name}` : entry.name;
2041
2291
  // Another mount's directory is passed over by its real path too, so a
2042
2292
  // walk that reaches one through a link to a directory above it lists
2043
- // none of that mount's names (nestedAtReal).
2044
- const passedOver = passedOverBecause(childRel, entry.name, ignore, view.others)
2045
- ?? (entry.isDirectory() && nestedAtReal(dir.at(entry.name), view.nested) !== undefined ? OTHER_MOUNT : undefined);
2293
+ // none of that mount's names (nestedRootAtReal).
2294
+ let passedOver = passedOverBecause(childRel, entry.name, ignore, view.others, entry.isDirectory())
2295
+ ?? (!entry.isSymbolicLink() && nestedRootAtReal(dir.at(entry.name), view.nested) !== undefined ? OTHER_MOUNT : undefined);
2296
+ if (passedOver === undefined && view.followSymlinks && entry.isSymbolicLink()) {
2297
+ const asDirectory = passedOverBecause(childRel, entry.name, ignore, view.others, true);
2298
+ if (asDirectory !== undefined && (await directoryAt(dir.at(entry.name), true)) === true) passedOver = asDirectory;
2299
+ }
2046
2300
  if (passedOver !== undefined) {
2047
2301
  others.push([childRel, passedOver]);
2048
2302
  regions.push({ path: childRel, reason: passedOver, kind: 'ignored' });
@@ -2082,7 +2336,7 @@ export async function walkScope(
2082
2336
  } else {
2083
2337
  const symlink = entry.isSymbolicLink();
2084
2338
  leaves.push([childRel, false]);
2085
- others.push([childRel, symlink ? 'a symlink' : 'not a regular file']);
2339
+ others.push([childRel, symlink ? 'a symlink' : NOT_REGULAR]);
2086
2340
  if (symlink) regions.push({ path: childRel, reason: LINK_NOT_WALKED, kind: 'symlink' });
2087
2341
  }
2088
2342
  }
@@ -2113,10 +2367,10 @@ export async function walkScope(
2113
2367
  };
2114
2368
 
2115
2369
  // Each nested root by which directory it is, read once, when the walk
2116
- // first descends: a directory the walk reaches through a link spelled
2117
- // otherwise than disk spells it, as a disk that folds names lets one, has
2118
- // a real path the check by path doesn't know, and is still another
2119
- // mount's directory.
2370
+ // first descends: a directory the walk reaches by a path that no
2371
+ // comparison of names places in the nested mount's directory, as through a
2372
+ // bind mount, or on a disk whose folding goes past the comparison's, is
2373
+ // still another mount's directory.
2120
2374
  let nestedRoots: Array<{ dev: string; ino: string }> | undefined;
2121
2375
  const isNestedRoot = async (dir: CheckedDir): Promise<boolean> => {
2122
2376
  if (!view.nested?.length) return false;
@@ -2176,7 +2430,7 @@ export async function walkScope(
2176
2430
  // directory there now: what the walk read of it describes another.
2177
2431
  dropBeneath(sub);
2178
2432
  if (child.kind === 'absent') walk.missing.add(sub);
2179
- else fail(sub, child.reason);
2433
+ else fail(sub, child.reason, child.errno);
2180
2434
  }
2181
2435
  };
2182
2436
 
@@ -2191,18 +2445,19 @@ export async function walkScope(
2191
2445
  // directories a listing showed that weren't listed. A walk that doesn't
2192
2446
  // descend leaves its subdirectories by design.
2193
2447
  const capped = `the walk cap (${cap}) was reached`;
2448
+ const raise = "whoever runs this host can raise the mount's walkCap";
2194
2449
  const holdingUntaken = new Set<string>();
2195
2450
  for (const f of walk.seen) holdingUntaken.add(parentOf(f));
2196
2451
  for (const [p, why] of walk.others) if (!passedOverByDesign(why) && !walk.othersTaken.has(p)) holdingUntaken.add(parentOf(p));
2197
2452
  for (const d of holdingUntaken) {
2198
- walk.incomplete.push({ path: d, reason: `${capped} before everything here was taken`, kind: 'cap' });
2453
+ walk.incomplete.push({ path: d, reason: `${capped} before everything here was taken; naming a path beneath it walks only that path, and ${raise}`, kind: 'cap' });
2199
2454
  }
2200
2455
  let unlisted = 0;
2201
2456
  if (recursive) {
2202
2457
  for (const d of walk.dirs) {
2203
2458
  if (visited.has(d) || walk.missing.has(d) || failed.has(d)) continue;
2204
2459
  unlisted++;
2205
- walk.incomplete.push({ path: d, reason: `not visited: ${capped}`, kind: 'cap' });
2460
+ walk.incomplete.push({ path: d, reason: `not visited: ${capped}; naming this path walks only it, and ${raise}`, kind: 'cap' });
2206
2461
  }
2207
2462
  }
2208
2463
  // The cap stopped the walk only if it left something: a walk whose last