@animalabs/agent-framework 0.23.2 → 0.24.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 (338) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +60 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +92 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/code-execution/engine.d.ts +177 -0
  10. package/dist/src/code-execution/engine.d.ts.map +1 -0
  11. package/dist/src/code-execution/engine.js +494 -0
  12. package/dist/src/code-execution/engine.js.map +1 -0
  13. package/dist/src/code-execution/index.d.ts +5 -10
  14. package/dist/src/code-execution/index.d.ts.map +1 -1
  15. package/dist/src/code-execution/index.js +5 -9
  16. package/dist/src/code-execution/index.js.map +1 -1
  17. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  18. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  19. package/dist/src/code-execution/isolate/calls.js +145 -0
  20. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  21. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  22. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  24. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  26. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-main.js +132 -0
  28. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  30. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child.js +315 -0
  32. package/dist/src/code-execution/isolate/child.js.map +1 -0
  33. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  34. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/frames.js +210 -0
  36. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  37. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  38. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/output.js +143 -0
  40. package/dist/src/code-execution/isolate/output.js.map +1 -0
  41. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  42. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/prelude.js +401 -0
  44. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  45. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  46. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/protocol.js +150 -0
  48. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  49. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  50. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/render.js +214 -0
  52. package/dist/src/code-execution/isolate/render.js.map +1 -0
  53. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  54. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/runner.js +854 -0
  56. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  57. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  58. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/scripts.js +357 -0
  60. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  61. package/dist/src/code-execution/quickjs.wasm +0 -0
  62. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  63. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  64. package/dist/src/code-execution/tool-definition.js +176 -142
  65. package/dist/src/code-execution/tool-definition.js.map +1 -1
  66. package/dist/src/deadline-timer.d.ts +3 -0
  67. package/dist/src/deadline-timer.d.ts.map +1 -1
  68. package/dist/src/deadline-timer.js +1 -1
  69. package/dist/src/deadline-timer.js.map +1 -1
  70. package/dist/src/framework.d.ts +1027 -440
  71. package/dist/src/framework.d.ts.map +1 -1
  72. package/dist/src/framework.js +4093 -2238
  73. package/dist/src/framework.js.map +1 -1
  74. package/dist/src/gate/event-gate.d.ts +28 -1
  75. package/dist/src/gate/event-gate.d.ts.map +1 -1
  76. package/dist/src/gate/event-gate.js +88 -10
  77. package/dist/src/gate/event-gate.js.map +1 -1
  78. package/dist/src/held-writes.d.ts +16 -3
  79. package/dist/src/held-writes.d.ts.map +1 -1
  80. package/dist/src/held-writes.js +16 -3
  81. package/dist/src/held-writes.js.map +1 -1
  82. package/dist/src/index.d.ts +6 -6
  83. package/dist/src/index.d.ts.map +1 -1
  84. package/dist/src/index.js +9 -8
  85. package/dist/src/index.js.map +1 -1
  86. package/dist/src/mcpl/channel-registry.d.ts +471 -75
  87. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  88. package/dist/src/mcpl/channel-registry.js +1673 -337
  89. package/dist/src/mcpl/channel-registry.js.map +1 -1
  90. package/dist/src/mcpl/conversation-router.d.ts +12 -7
  91. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  92. package/dist/src/mcpl/conversation-router.js +12 -7
  93. package/dist/src/mcpl/conversation-router.js.map +1 -1
  94. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  95. package/dist/src/mcpl/convert-block.js +9 -5
  96. package/dist/src/mcpl/convert-block.js.map +1 -1
  97. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  98. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  99. package/dist/src/mcpl/inbound-source.js +130 -0
  100. package/dist/src/mcpl/inbound-source.js.map +1 -1
  101. package/dist/src/mcpl/index.d.ts +2 -1
  102. package/dist/src/mcpl/index.d.ts.map +1 -1
  103. package/dist/src/mcpl/index.js +3 -2
  104. package/dist/src/mcpl/index.js.map +1 -1
  105. package/dist/src/mcpl/log-value.d.ts +6 -0
  106. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  107. package/dist/src/mcpl/log-value.js +8 -0
  108. package/dist/src/mcpl/log-value.js.map +1 -1
  109. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  110. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  111. package/dist/src/mcpl/modern-connection.js +168 -53
  112. package/dist/src/mcpl/modern-connection.js.map +1 -1
  113. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  114. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  115. package/dist/src/mcpl/prose-grammar.js +4 -5
  116. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  117. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  118. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  119. package/dist/src/mcpl/protocol-family.js +173 -16
  120. package/dist/src/mcpl/protocol-family.js.map +1 -1
  121. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  122. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  123. package/dist/src/mcpl/push-coalescer.js +227 -50
  124. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  125. package/dist/src/mcpl/push-handler.d.ts +6 -1
  126. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-handler.js +4 -1
  128. package/dist/src/mcpl/push-handler.js.map +1 -1
  129. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  130. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  131. package/dist/src/mcpl/reference-fetcher.js +4 -4
  132. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  133. package/dist/src/mcpl/server-connection.d.ts +50 -21
  134. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  135. package/dist/src/mcpl/server-connection.js +158 -74
  136. package/dist/src/mcpl/server-connection.js.map +1 -1
  137. package/dist/src/mcpl/tags.d.ts +24 -0
  138. package/dist/src/mcpl/tags.d.ts.map +1 -1
  139. package/dist/src/mcpl/tags.js +33 -0
  140. package/dist/src/mcpl/tags.js.map +1 -1
  141. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  142. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  143. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  144. package/dist/src/mcpl/transport.d.ts +55 -7
  145. package/dist/src/mcpl/transport.d.ts.map +1 -1
  146. package/dist/src/mcpl/transport.js +163 -34
  147. package/dist/src/mcpl/transport.js.map +1 -1
  148. package/dist/src/mcpl/types.d.ts +22 -6
  149. package/dist/src/mcpl/types.d.ts.map +1 -1
  150. package/dist/src/mcpl/types.js.map +1 -1
  151. package/dist/src/module-registry.d.ts +4 -2
  152. package/dist/src/module-registry.d.ts.map +1 -1
  153. package/dist/src/module-registry.js +100 -13
  154. package/dist/src/module-registry.js.map +1 -1
  155. package/dist/src/modules/discord/index.d.ts.map +1 -1
  156. package/dist/src/modules/discord/index.js +8 -13
  157. package/dist/src/modules/discord/index.js.map +1 -1
  158. package/dist/src/modules/history/index.d.ts.map +1 -1
  159. package/dist/src/modules/history/index.js +27 -4
  160. package/dist/src/modules/history/index.js.map +1 -1
  161. package/dist/src/modules/history/semantic.d.ts +15 -3
  162. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  163. package/dist/src/modules/history/semantic.js +27 -5
  164. package/dist/src/modules/history/semantic.js.map +1 -1
  165. package/dist/src/modules/index.d.ts +1 -1
  166. package/dist/src/modules/index.d.ts.map +1 -1
  167. package/dist/src/modules/index.js.map +1 -1
  168. package/dist/src/modules/unknown-tool.d.ts +4 -1
  169. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  170. package/dist/src/modules/unknown-tool.js +4 -1
  171. package/dist/src/modules/unknown-tool.js.map +1 -1
  172. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  173. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  174. package/dist/src/modules/workspace/branch-intent.js +3 -1
  175. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  176. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  177. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  178. package/dist/src/modules/workspace/edit-plan.js +794 -0
  179. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  180. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  181. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-text.js +584 -0
  183. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  184. package/dist/src/modules/workspace/effects.d.ts +5 -4
  185. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/effects.js +78 -25
  187. package/dist/src/modules/workspace/effects.js.map +1 -1
  188. package/dist/src/modules/workspace/index.d.ts +484 -61
  189. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/index.js +2173 -645
  191. package/dist/src/modules/workspace/index.js.map +1 -1
  192. package/dist/src/modules/workspace/observe.d.ts +213 -38
  193. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/observe.js +416 -36
  195. package/dist/src/modules/workspace/observe.js.map +1 -1
  196. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/reconcile.js +15 -8
  198. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  199. package/dist/src/modules/workspace/text.d.ts +32 -0
  200. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  201. package/dist/src/modules/workspace/text.js +67 -0
  202. package/dist/src/modules/workspace/text.js.map +1 -0
  203. package/dist/src/modules/workspace/types.d.ts +210 -6
  204. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  205. package/dist/src/modules/workspace/types.js.map +1 -1
  206. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  207. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  208. package/dist/src/modules/workspace/watcher.js.map +1 -1
  209. package/dist/src/operator-log.d.ts +52 -24
  210. package/dist/src/operator-log.d.ts.map +1 -1
  211. package/dist/src/operator-log.js.map +1 -1
  212. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  213. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  214. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  215. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  216. package/dist/src/recovery/offline-branch.d.ts +26 -1
  217. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  218. package/dist/src/recovery/offline-branch.js +164 -39
  219. package/dist/src/recovery/offline-branch.js.map +1 -1
  220. package/dist/src/recovery/recover-cli.js +23 -4
  221. package/dist/src/recovery/recover-cli.js.map +1 -1
  222. package/dist/src/send-tools.d.ts +11 -0
  223. package/dist/src/send-tools.d.ts.map +1 -0
  224. package/dist/src/send-tools.js +11 -0
  225. package/dist/src/send-tools.js.map +1 -0
  226. package/dist/src/tool-result-guard.d.ts +4 -3
  227. package/dist/src/tool-result-guard.d.ts.map +1 -1
  228. package/dist/src/tool-result-guard.js +5 -4
  229. package/dist/src/tool-result-guard.js.map +1 -1
  230. package/dist/src/tool-result-history.d.ts +1 -1
  231. package/dist/src/tool-result-history.js +1 -1
  232. package/dist/src/tune-out/coordinator.d.ts +21 -0
  233. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  234. package/dist/src/tune-out/coordinator.js +139 -37
  235. package/dist/src/tune-out/coordinator.js.map +1 -1
  236. package/dist/src/types/agent.d.ts +4 -0
  237. package/dist/src/types/agent.d.ts.map +1 -1
  238. package/dist/src/types/events.d.ts +62 -9
  239. package/dist/src/types/events.d.ts.map +1 -1
  240. package/dist/src/types/framework.d.ts +62 -58
  241. package/dist/src/types/framework.d.ts.map +1 -1
  242. package/dist/src/types/module.d.ts +40 -8
  243. package/dist/src/types/module.d.ts.map +1 -1
  244. package/dist/src/types/trace.d.ts +111 -10
  245. package/dist/src/types/trace.d.ts.map +1 -1
  246. package/dist/src/usage/types.d.ts +16 -0
  247. package/dist/src/usage/types.d.ts.map +1 -1
  248. package/dist/src/usage/usage-tracker.d.ts +14 -1
  249. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  250. package/dist/src/usage/usage-tracker.js +81 -41
  251. package/dist/src/usage/usage-tracker.js.map +1 -1
  252. package/package.json +6 -5
  253. package/src/agent.ts +93 -12
  254. package/src/api/server.ts +14 -12
  255. package/src/code-execution/engine.ts +595 -0
  256. package/src/code-execution/index.ts +5 -17
  257. package/src/code-execution/isolate/calls.ts +199 -0
  258. package/src/code-execution/isolate/child-entry.ts +5 -0
  259. package/src/code-execution/isolate/child-main.ts +132 -0
  260. package/src/code-execution/isolate/child.ts +327 -0
  261. package/src/code-execution/isolate/frames.ts +209 -0
  262. package/src/code-execution/isolate/output.ts +161 -0
  263. package/src/code-execution/isolate/prelude.ts +409 -0
  264. package/src/code-execution/isolate/protocol.ts +240 -0
  265. package/src/code-execution/isolate/render.ts +230 -0
  266. package/src/code-execution/isolate/runner.ts +911 -0
  267. package/src/code-execution/isolate/scripts.ts +479 -0
  268. package/src/code-execution/quickjs.wasm +0 -0
  269. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  270. package/src/code-execution/tool-definition.ts +236 -167
  271. package/src/deadline-timer.ts +1 -1
  272. package/src/framework.ts +4468 -2418
  273. package/src/gate/event-gate.ts +78 -11
  274. package/src/held-writes.ts +17 -3
  275. package/src/index.ts +30 -22
  276. package/src/mcpl/channel-registry.ts +1825 -368
  277. package/src/mcpl/conversation-router.ts +12 -7
  278. package/src/mcpl/convert-block.ts +9 -5
  279. package/src/mcpl/inbound-source.ts +131 -0
  280. package/src/mcpl/index.ts +4 -2
  281. package/src/mcpl/log-value.ts +9 -0
  282. package/src/mcpl/modern-connection.ts +162 -53
  283. package/src/mcpl/prose-grammar.ts +4 -5
  284. package/src/mcpl/protocol-family.ts +199 -21
  285. package/src/mcpl/push-coalescer.ts +306 -62
  286. package/src/mcpl/push-handler.ts +10 -2
  287. package/src/mcpl/reference-fetcher.ts +4 -4
  288. package/src/mcpl/server-connection.ts +180 -79
  289. package/src/mcpl/tags.ts +36 -0
  290. package/src/mcpl/tool-result-normalize.ts +2 -1
  291. package/src/mcpl/transport.ts +162 -33
  292. package/src/mcpl/types.ts +23 -6
  293. package/src/module-registry.ts +92 -13
  294. package/src/modules/discord/index.ts +8 -13
  295. package/src/modules/history/index.ts +25 -3
  296. package/src/modules/history/semantic.ts +29 -6
  297. package/src/modules/index.ts +1 -0
  298. package/src/modules/unknown-tool.ts +4 -1
  299. package/src/modules/workspace/branch-intent.ts +3 -1
  300. package/src/modules/workspace/edit-plan.ts +954 -0
  301. package/src/modules/workspace/edit-text.ts +618 -0
  302. package/src/modules/workspace/effects.ts +70 -19
  303. package/src/modules/workspace/index.ts +2247 -642
  304. package/src/modules/workspace/observe.ts +465 -56
  305. package/src/modules/workspace/reconcile.ts +14 -7
  306. package/src/modules/workspace/text.ts +68 -0
  307. package/src/modules/workspace/types.ts +211 -6
  308. package/src/modules/workspace/watcher.ts +3 -2
  309. package/src/operator-log.ts +47 -19
  310. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  311. package/src/recovery/offline-branch.ts +189 -55
  312. package/src/recovery/recover-cli.ts +25 -4
  313. package/src/send-tools.ts +11 -0
  314. package/src/tool-result-guard.ts +6 -5
  315. package/src/tool-result-history.ts +1 -1
  316. package/src/tune-out/coordinator.ts +163 -42
  317. package/src/types/agent.ts +4 -0
  318. package/src/types/events.ts +62 -9
  319. package/src/types/framework.ts +62 -58
  320. package/src/types/module.ts +40 -8
  321. package/src/types/trace.ts +121 -15
  322. package/src/usage/types.ts +16 -0
  323. package/src/usage/usage-tracker.ts +86 -43
  324. package/dist/src/code-execution/py-runner.d.ts +0 -191
  325. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  326. package/dist/src/code-execution/py-runner.js +0 -655
  327. package/dist/src/code-execution/py-runner.js.map +0 -1
  328. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  329. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  330. package/dist/src/code-execution/runtime-py.js +0 -677
  331. package/dist/src/code-execution/runtime-py.js.map +0 -1
  332. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  333. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  334. package/dist/src/mcpl/prose-stream-router.js +0 -291
  335. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  336. package/src/code-execution/py-runner.ts +0 -791
  337. package/src/code-execution/runtime-py.ts +0 -677
  338. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -32,8 +32,12 @@
32
32
  * a directory is checked by its canonical path and identity, not held open:
33
33
  * a parent replaced concurrently in the window between a check and its use
34
34
  * (or replaced and restored around it) can still redirect a listing or
35
- * lookup there. File contents are bound regardless: a file is read only
36
- * through a descriptor bound to its path first.
35
+ * lookup there. File contents are bound more tightly: a file is read only
36
+ * through a descriptor whose identity matches the file its path named, as a
37
+ * check before the open found it (hashAt) or as the path's realpath after
38
+ * the open finds it (openContainedFile), though a link put back after that
39
+ * realpath, leading to the opened file itself, still passes the check
40
+ * (sameLevels).
37
41
  */
38
42
 
39
43
  import { createHash } from 'node:crypto';
@@ -45,7 +49,11 @@ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } fr
45
49
  import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
46
50
 
47
51
  export interface MountView {
48
- /** Mount root as configured. */
52
+ /**
53
+ * The directory its readers read beneath: the mount's root as configured,
54
+ * or, for a direct mount, the root its view resolved (diskView), so a
55
+ * walk opens what it judges beneath one root.
56
+ */
49
57
  root: string;
50
58
  /** The mount's name, which a tool puts before a path in the mount. */
51
59
  name: string;
@@ -58,33 +66,35 @@ export interface MountView {
58
66
  /** Text above this size is not ingested. */
59
67
  maxFileSize: number;
60
68
  ignore: string[];
69
+ /** The mount's `include`: files and links it doesn't cover are outside the mount. Absent, everything is inside. */
70
+ include?: readonly string[];
61
71
  /**
62
72
  * Other mounts' directories nested inside this one, mount-relative. Every
63
73
  * pass passes over them, named or not, as another mount's directory.
64
74
  */
65
75
  others: readonly string[];
66
76
  /**
67
- * The same mounts, each by its directory, its name and, where it has one,
68
- * its real root (NestedMount), for a refusal that names the mount a path
69
- * leads into: through a symlink this mount doesn't follow (linkWords), or
70
- * through one it follows, or a root configured through one (nestedAtReal).
71
- * Without it, no path is refused for where its links lead, and a link's
72
- * refusal names no mount.
77
+ * The same mounts, each by its directory, its name, its own include and,
78
+ * where it has one, its real root (NestedMount), for a refusal that names
79
+ * the mount a path leads into: through a symlink this mount doesn't follow
80
+ * (linkWords), or through one it follows, or a root configured through one
81
+ * (nestedAtReal). Without it, no path is refused for where its links lead,
82
+ * and a link's refusal names no mount.
73
83
  */
74
84
  nested?: readonly NestedMount[];
75
85
  }
76
86
 
77
87
  /**
78
88
  * A mount nested in another: its directory in one of its spellings,
79
- * mount-relative and `/`-separated, its name, and its real root, which
80
- * every spelling shares. The real root is the root as realpath resolves it
81
- * (the nearest existing directory's, where the root isn't made yet) once
82
- * the module's start has read it, and the root as configured until then.
83
- * There's none where it's this mount's own or holds it: the mount is nested
84
- * by its configured path alone, since through its real root it would claim
85
- * this mount's every path.
89
+ * mount-relative and `/`-separated, its name, its real root, which every
90
+ * spelling shares, and its own include. The real root is the root as
91
+ * realpath resolves it (the nearest existing directory's, where the root
92
+ * isn't made yet) once the module's start has read it, and the root as
93
+ * configured until then. There's none where it's this mount's own or holds
94
+ * it: the mount is nested by its configured path alone, since through its
95
+ * real root it would claim this mount's every path.
86
96
  */
87
- export type NestedMount = { dir: string; name: string; real?: string };
97
+ export type NestedMount = { dir: string; name: string; real?: string; include?: readonly string[] };
88
98
 
89
99
  export type StatFingerprint = Omit<Fingerprint, 'hashedAt'>;
90
100
 
@@ -247,9 +257,12 @@ export async function linkRefusal(
247
257
 
248
258
  /**
249
259
  * Where a symlink the walk found leads, for its refusal: to a path inside
250
- * the mount (mount-relative, and whether it's a directory), outside the
251
- * mount, or nowhere it can be resolved to. One realpath and one stat, made
252
- * only when refusing.
260
+ * 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
262
+ * realpath, one stat and the respelling, made only when refusing. realpath
263
+ * keeps the spelling the link's target gives, which on a disk that folds
264
+ * names need not be disk's, so the target is respelled before anything
265
+ * judges or names it (diskSpelling).
253
266
  */
254
267
  export type LinkLeads = { kind: 'inside'; target: string; directory: boolean } | { kind: 'outside' } | { kind: 'nowhere' };
255
268
 
@@ -258,7 +271,7 @@ export async function linkLeads(root: string, rootReal: string, link: string): P
258
271
  if (leads === null) return { kind: 'nowhere' };
259
272
  if (!contained(rootReal, leads)) return { kind: 'outside' };
260
273
  const directory = await stat(leads).then((info) => info.isDirectory(), () => false);
261
- return { kind: 'inside', target: relative(rootReal, leads).split(sep).join('/'), directory };
274
+ return { kind: 'inside', target: await diskSpelling(rootReal, relative(rootReal, leads).split(sep).join('/')), directory };
262
275
  }
263
276
 
264
277
  /**
@@ -266,12 +279,119 @@ export async function linkLeads(root: string, rootReal: string, link: string): P
266
279
  * innermost where nested mounts nest, as #460 picks one; undefined when none
267
280
  * does.
268
281
  */
269
- export function nestedHolder(target: string, nested: readonly { dir: string; name: string }[]): { dir: string; name: string } | undefined {
282
+ export function nestedHolder<N extends { dir: string; name: string }>(target: string, nested: readonly N[]): N | undefined {
270
283
  return nested
271
284
  .filter((n) => target === n.dir || target.startsWith(`${n.dir}/`))
272
285
  .sort((a, b) => b.dir.length - a.dir.length)[0];
273
286
  }
274
287
 
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);
297
+ }
298
+
299
+ /**
300
+ * Whether a refusal names a place in a nested mount's directory, `rest`
301
+ * beneath that mount's root, as a place of that mount's: a directory, or a
302
+ * file its include covers. The place is one of that mount's own paths, so
303
+ * its include decides, and a refusal's code follows the same test, so the
304
+ * code says no more than the message. Anywhere else the place is no mount's:
305
+ * the outer mount passes over the nested directory, and the nested mount's
306
+ * include leaves it out. So a link the outer mount doesn't follow is refused
307
+ * as one it doesn't follow (namesHolder), and a path that reaches the place
308
+ * through one it follows is refused as one that leaves the mount, in the
309
+ * words each such refusal already has (nestedAtReal's callers).
310
+ */
311
+ export function namesNestedPlace(rest: string, include: readonly string[] | undefined, directory: boolean): boolean {
312
+ return directory || insideMount(rest, include);
313
+ }
314
+
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
+ export function withinIncludes(view: Pick<MountView, 'include' | 'nested'>, path: string): boolean {
324
+ 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);
327
+ }
328
+
329
+ /**
330
+ * The path a mount's include judges for `given`, one of the mount's paths,
331
+ * which landed at `real`, its canonical path, inside the mount and in no
332
+ * nested mount's directory. Where realpath couldn't resolve it, as for a loop
333
+ * or a directory this process can't search, `real` is the path as
334
+ * path.resolve reads it, which this judges as named, so a caller refuses such
335
+ * a path first, as landingRefusal does (realBeneath's `unresolved`). The
336
+ * include is the operator's statement of which files' content the workspace
337
+ * holds, and a link is a path, not a grant of content, so where a followed
338
+ * path lands is judged as where it's named, as a nested mount's place is
339
+ * (namesNestedPlace). A path that meets no link lands where it's named, and
340
+ * is judged as named. realpath can spell such a path otherwise only as a disk
341
+ * that folds names does, with disk's stored names on macOS and Windows where
342
+ * a path keeps a workspace entry's spelling, so where the spellings differ
343
+ * only so, the path is judged as named where it reaches the landing itself,
344
+ * level by level (sameLevels). Otherwise the landing is judged, as disk
345
+ * spells it, since realpath keeps the spelling a link's target gives, which
346
+ * on a disk that folds names need not be disk's (diskSpelling).
347
+ */
348
+ export async function judgedAt(rootReal: string, real: string, given: string): Promise<string> {
349
+ const landed = relative(rootReal, real).split(sep).join('/');
350
+ if (landed === given) return given;
351
+ if (foldedLoosely(landed) === foldedLoosely(given) && await sameLevels(rootReal, given, landed)) return given;
352
+ return diskSpelling(rootReal, landed);
353
+ }
354
+
355
+ /**
356
+ * Whether `given` reaches `landed`, two spellings of a path beneath
357
+ * `rootReal`, level by level: each directory on the way and the file itself
358
+ * are, as `lstat` finds them, one and the same on both sides, so the path as
359
+ * named passes through no link of its own that the landing doesn't. Beneath
360
+ * the last level both sides have, where neither has the next, a file a write
361
+ * would create there lands where it's named. A level only one side has, or
362
+ * one this can't ask, is no such path. It asks each level once, in turn: on a
363
+ * disk that folds names a level both spellings share is one entry, so a link
364
+ * disk puts there after the realpath meets both alike, and a name removed
365
+ * while this asks and put back after reads as a file a write would create.
366
+ * Where a read follows, its check of the opened file catches a link to
367
+ * another file, but not one back to the opened file itself; that race, like
368
+ * one where the spellings match, isn't closed here.
369
+ */
370
+ async function sameLevels(rootReal: string, given: string, landed: string): Promise<boolean> {
371
+ const named = given.split('/');
372
+ const landing = landed.split('/');
373
+ const level = (segments: string[], i: number) => lstat(join(rootReal, ...segments.slice(0, i)), { bigint: true })
374
+ .then((st) => st, (err: unknown) => (errno(err) === 'ENOENT' || errno(err) === 'ENOTDIR' ? null : undefined));
375
+ for (let i = 1; i <= named.length; i++) {
376
+ const [a, b] = await Promise.all([level(named, i), level(landing, i)]);
377
+ if (a === undefined || b === undefined) return false;
378
+ if (a === null && b === null) return true;
379
+ if (a === null || b === null || !sameFile(a, b)) return false;
380
+ }
381
+ return true;
382
+ }
383
+
384
+ /**
385
+ * Whether a mount's own include covers the file one of its paths, `given`,
386
+ * lands on at `real` (judgedAt): a file it leaves out there is no file of
387
+ * this mount's, and a path that reaches it through a link is refused as one
388
+ * that leaves the mount, in the words each such refusal already has
389
+ * (nestedAtReal's callers). A mount without an include covers every file.
390
+ */
391
+ export async function coversLanding(view: Pick<MountView, 'include'>, rootReal: string, real: string, given: string): Promise<boolean> {
392
+ return view.include === undefined || insideMount(await judgedAt(rootReal, real, given), view.include);
393
+ }
394
+
275
395
  /**
276
396
  * Why a path is refused for what linkOnTheWay found, given where a link
277
397
  * leads (linkLeads). For a link, the link is the subject, with where it
@@ -284,9 +404,15 @@ export function nestedHolder(target: string, nested: readonly { dir: string; nam
284
404
  * path to use there in place of the link, and offers no setting, since
285
405
  * following the link would only reach into another mount's directory. For a
286
406
  * directory the walk couldn't ask, which one, and the answer it got. Paths
287
- * are spelled as the tools take them, from the mount's name.
407
+ * are spelled as the tools take them, from the mount's name. On a mount with
408
+ * an include, an inside target is named only where a tool could name it: a
409
+ * file the include covers, or a directory, which a listing names whatever
410
+ * lies beneath it. Anywhere else the refusal says only that the link isn't
411
+ * followed, so no tool names what lies outside the include. A link the
412
+ * include doesn't cover is outside the mount itself, so its refusal tells
413
+ * nothing of where it leads (shownLeads gives null for it).
288
414
  */
289
- export function linkWords(name: string, found: LinkFound, leads: LinkLeads | null, nested: readonly { dir: string; name: string }[] = []): string {
415
+ export function linkWords(name: string, found: LinkFound, leads: LinkLeads | null, nested: readonly NestedMount[] = [], include?: readonly string[]): string {
290
416
  const spell = (rel: string) => `"${rel === '' ? name : `${name}/${rel}`}"`;
291
417
  if (!('link' in found)) return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${found.code})`;
292
418
  const link = `${spell(found.link)} is a symlink`;
@@ -294,33 +420,52 @@ export function linkWords(name: string, found: LinkFound, leads: LinkLeads | nul
294
420
  if (leads.kind === 'outside') return `${link} leading outside the mount, which this mount does not follow`;
295
421
  const holder = nestedHolder(leads.target, nested);
296
422
  if (holder) {
423
+ if (!namesHolder(leads, holder)) return `${link}, which this mount does not follow`;
297
424
  const rest = leads.target.slice(holder.dir.length + 1);
298
425
  return `${link} to ${spell(leads.target)}, which this mount does not follow; it lies in mount "${holder.name}", ` +
299
426
  `nested in mount "${name}": use "${rest ? `${holder.name}/${rest}` : holder.name}" in place of ${spell(found.link)}`;
300
427
  }
301
- const setting = leads.directory ? "; the mount's host can set followSymlinks: true" : '';
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";
302
432
  return `${link} to ${spell(leads.target)}, which this mount does not follow${setting}`;
303
433
  }
304
434
 
305
- /** Why a path is refused for what linkOnTheWay found (linkWords, with where the link leads). */
306
- export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested'>, rootReal: string, found: LinkFound): Promise<string> {
307
- return linkWords(view.name, found, 'link' in found ? await linkLeads(view.root, rootReal, found.link) : null, view.nested);
435
+ /**
436
+ * Where a link linkOnTheWay found leads, as its refusal may tell it
437
+ * (linkLeads), or null where the refusal tells nothing of it: where the walk
438
+ * found a directory it couldn't ask rather than a link, and where the mount's
439
+ * include doesn't cover the link, which is then outside the mount, so its
440
+ * refusal shows only that a link stands on the path. A refusal's code
441
+ * follows the same answer (linkReadError), so the code says no more than the
442
+ * message.
443
+ */
444
+ export async function shownLeads(root: string, rootReal: string, found: LinkFound, include: readonly string[] | undefined): Promise<LinkLeads | null> {
445
+ return 'link' in found && insideMount(found.link, include) ? linkLeads(root, rootReal, found.link) : null;
446
+ }
447
+
448
+ /** 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);
308
451
  }
309
452
 
310
453
  /**
311
454
  * The nested mount whose root holds `real`, a canonical path, the innermost,
312
- * with the path to use there; undefined when none does. A path the lexical
313
- * checks pass (inOtherMount, the tools' holderOf) reaches another mount's
314
- * directory this way through a symlink the mount follows, so each check that
315
- * resolves a path canonically asks this too.
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
457
+ * (inOtherMount, the tools' holderOf) reaches another mount's directory this
458
+ * 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.
316
461
  */
317
- export function nestedAtReal(real: string, nested: readonly NestedMount[] = []): { name: string; use: string } | undefined {
462
+ export function nestedAtReal(real: string, nested: readonly NestedMount[] = []): { name: string; use: string; rest: string; include?: readonly string[] } | undefined {
318
463
  const holder = nested
319
- .flatMap((n) => (n.real !== undefined && contained(n.real, real) ? [{ name: n.name, real: n.real }] : []))
464
+ .flatMap((n) => (n.real !== undefined && contained(n.real, real) ? [{ name: n.name, real: n.real, include: n.include }] : []))
320
465
  .sort((a, b) => b.real.length - a.real.length)[0];
321
466
  if (!holder) return undefined;
322
467
  const rest = relative(holder.real, real).split(sep).join('/');
323
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
468
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, rest, include: holder.include };
324
469
  }
325
470
 
326
471
  /**
@@ -471,6 +616,20 @@ function entryName(paths: readonly string[], prefix: string, segment: string): s
471
616
  return found;
472
617
  }
473
618
 
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)));
631
+ }
632
+
474
633
  /**
475
634
  * The name `dir` lists for `segment`: `segment` itself, unless disk folds it
476
635
  * there and lists it otherwise. `dir` is listed only where a variant of
@@ -770,9 +929,14 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
770
929
  return { kind: 'unobserved', reason: OUTSIDE_PARENT, outside: true };
771
930
  } else {
772
931
  // Through a link this mount follows, a directory can be a nested
773
- // mount's, which the mount neither reads nor writes.
932
+ // mount's, which the mount neither reads nor writes. A place there that
933
+ // mount's include leaves out is no mount's, and holds nothing beneath it,
934
+ // as at any file (namesNestedPlace).
774
935
  const holder = nestedAtReal(real, view.nested);
775
- if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, rel, holder) };
936
+ 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' };
939
+ }
776
940
  }
777
941
  const checked = await checkedAt(view, rel, real);
778
942
  if (checked) {
@@ -997,19 +1161,24 @@ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
997
1161
  // ===========================================================================
998
1162
 
999
1163
  /**
1000
- * Where a path's parent directory canonically lies: inside the mount, gone
1001
- * (absence is then proven separately), or somewhere a no-follow open would
1002
- * still reach through — O_NOFOLLOW guards only the final component — which
1003
- * on a mount that doesn't follow symlinks is any symlink on the way.
1164
+ * Where a path's parent directory canonically lies: inside the mount, with
1165
+ * that real path, gone (absence is then proven separately), or somewhere a
1166
+ * no-follow open would still reach through — O_NOFOLLOW guards only the
1167
+ * final component — which on a mount that doesn't follow symlinks is any
1168
+ * symlink on the way.
1004
1169
  */
1005
- async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<'inside' | 'gone' | { reason: string }> {
1170
+ async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<{ inside: string } | 'gone' | { reason: string }> {
1006
1171
  try {
1007
1172
  const parent = await realpath(dirname(join(view.root, relativePath)));
1008
1173
  const found = await linkRefusal(view, rootReal, relativePath, parent, 'parents');
1009
1174
  if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1010
1175
  if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT };
1011
1176
  const holder = nestedAtReal(parent, view.nested);
1012
- return holder ? { reason: leadsInto(view.name, parentOf(relativePath), holder) } : 'inside';
1177
+ if (!holder) return { inside: parent };
1178
+ // A place there that the nested mount's include leaves out is no mount's,
1179
+ // 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';
1013
1182
  } catch (err) {
1014
1183
  const code = errno(err);
1015
1184
  if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
@@ -1031,7 +1200,7 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1031
1200
  const lexical = join(view.root, relativePath);
1032
1201
  const placement = await parentPlacement(view, rootReal, relativePath);
1033
1202
  if (placement === 'gone') return absence(view, rootReal, relativePath);
1034
- if (placement !== 'inside') return { kind: 'unobserved', reason: placement.reason };
1203
+ if ('reason' in placement) return { kind: 'unobserved', reason: placement.reason };
1035
1204
  let info;
1036
1205
  try {
1037
1206
  info = await lstat(lexical, { bigint: true });
@@ -1056,15 +1225,27 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1056
1225
  }
1057
1226
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1058
1227
  const holder = nestedAtReal(real, view.nested);
1059
- if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder) };
1228
+ // A place a nested mount's include leaves out is no mount's, so the link
1229
+ // leaves this one (namesNestedPlace).
1230
+ 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' };
1232
+ }
1060
1233
  if (!target.isFile()) {
1061
1234
  return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
1062
1235
  }
1236
+ // A file this mount's own include leaves out where the link lands is no
1237
+ // file of this mount's (coversLanding). It's judged here, and not only
1238
+ // where the file is opened, since a pass takes a copy whose fingerprint
1239
+ // 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' };
1063
1241
  info = target;
1064
1242
  } else if (info.isDirectory()) {
1065
1243
  return { kind: 'unobserved', reason: 'a directory' };
1066
1244
  } else if (!info.isFile()) {
1067
1245
  return { kind: 'unobserved', reason: 'not a regular file' };
1246
+ } else if (!(await coversLanding(view, rootReal, join(placement.inside, basename(lexical)), relativePath))) {
1247
+ // So is one reached through a link among its directories.
1248
+ return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1068
1249
  }
1069
1250
  return { kind: 'file', size: Number(info.size), mtimeMs: Number(info.mtimeMs), mode: Number(info.mode & 0o777n), fp: statFingerprint(info) };
1070
1251
  }
@@ -1117,11 +1298,14 @@ async function openContained(
1117
1298
  const found = await linkRefusal(view, rootReal, relativePath, real);
1118
1299
  if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1119
1300
  if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? 'a directory' : 'not a regular file' };
1120
- if (!contained(rootReal, real)) {
1121
- return { kind: 'unobserved', reason: view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT };
1122
- }
1301
+ const leaves = view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT;
1302
+ if (!contained(rootReal, real)) return { kind: 'unobserved', reason: leaves };
1123
1303
  const holder = nestedAtReal(real, view.nested);
1124
- if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder) };
1304
+ // A place a nested mount's include leaves out is no mount's. Reached
1305
+ // 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 };
1307
+ // So is a file this mount's own include leaves out (coversLanding).
1308
+ if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: view.followSymlinks ? leaves : NOT_FOLLOWED };
1125
1309
  const named = await stat(real, { bigint: true }).catch(() => null);
1126
1310
  if (named === null || named.dev !== info.dev || named.ino !== info.ino) {
1127
1311
  return { kind: 'unobserved', reason: 'the file changed while it was read' };
@@ -1245,6 +1429,66 @@ export async function readPath(
1245
1429
  }
1246
1430
  }
1247
1431
 
1432
+ /** A file's bytes as readBytes read them, none hashed. */
1433
+ export interface BytesResult {
1434
+ kind: 'bytes';
1435
+ /**
1436
+ * Its size in bytes: as read, for a file within the limit; as its fstat
1437
+ * says, for one over it, and at least one past the limit where the reads
1438
+ * found it grown past it.
1439
+ */
1440
+ size: number;
1441
+ /** The whole file, when it is at most the limit both at its fstat and as read; null otherwise. */
1442
+ bytes: Buffer | null;
1443
+ /** Its first bytes (at most HEAD_BYTES), for binary detection and MIME sniffing. */
1444
+ head: Buffer;
1445
+ }
1446
+
1447
+ /**
1448
+ * A mount-relative file's bytes, through the same located descriptor as
1449
+ * readPath, for a direct mount's tools, which keep nothing: at most `limit`
1450
+ * of them, and none hashed. A file over `limit` at its fstat has only its
1451
+ * head read, and one that grew past it since is read no further than it.
1452
+ * What the open refuses gets the reason a listing gives it, such as a link
1453
+ * to a directory, except a regular file it couldn't open, which keeps the
1454
+ * open's.
1455
+ */
1456
+ export async function readBytes(
1457
+ view: MountView,
1458
+ rootReal: string,
1459
+ relativePath: string,
1460
+ limit: number,
1461
+ ): Promise<BytesResult | Exclude<Observation, { kind: 'file' }>> {
1462
+ const opened = await openContained(view, rootReal, relativePath);
1463
+ if (!('handle' in opened)) {
1464
+ // A walk takes a link to a directory as an entry, where the mount follows
1465
+ // links, and the open may refuse it as a directory or, where it can't
1466
+ // be opened, by its errno: either way it's named as a listing names it
1467
+ // (observePath).
1468
+ if (opened.kind === 'unobserved') {
1469
+ const seen = await observePath(view, rootReal, relativePath);
1470
+ return seen.kind === 'file' ? opened : seen;
1471
+ }
1472
+ return opened;
1473
+ }
1474
+ const { handle, info } = opened;
1475
+ try {
1476
+ const size = Number(info.size);
1477
+ if (size > limit) {
1478
+ const head = Buffer.alloc(Math.min(HEAD_BYTES, size));
1479
+ const { bytesRead } = await handle.read(head, 0, head.length, 0);
1480
+ return { kind: 'bytes', size, bytes: null, head: head.subarray(0, bytesRead) };
1481
+ }
1482
+ const { bytes, more } = await readPrefix(handle, limit, size);
1483
+ 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
+ return { kind: 'bytes', size: bytes.byteLength, bytes, head: bytes.subarray(0, HEAD_BYTES) };
1485
+ } catch (err) {
1486
+ return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
1487
+ } finally {
1488
+ await handle.close();
1489
+ }
1490
+ }
1491
+
1248
1492
  /**
1249
1493
  * The first bytes of a mount-relative file and its size, through the same
1250
1494
  * located descriptor as readPath — for listing a file nothing tracks, which
@@ -1310,6 +1554,15 @@ export interface Walk {
1310
1554
  * file name): present, unproven.
1311
1555
  */
1312
1556
  others: Map<string, string>;
1557
+ /**
1558
+ * The entries in `others` that the mount's include doesn't cover and that
1559
+ * are neither directories nor links (a regular file, a FIFO, a socket or a
1560
+ * device): present, unproven, and holding nothing beneath them. Only provenAbsent
1561
+ * reads it, for a directory listed whole. A path beneath a name passed
1562
+ * over by design is never decided (reconcile), so those need no such
1563
+ * record.
1564
+ */
1565
+ otherFiles: Set<string>;
1313
1566
  /**
1314
1567
  * Directories whose listing the walk read whole, and that were still the
1315
1568
  * directory at their path once it had: every name in one is in `files`,
@@ -1395,6 +1648,84 @@ export function coveredByIgnore(relativePath: string, patterns: string[]): boole
1395
1648
  return false;
1396
1649
  }
1397
1650
 
1651
+ /** Why a walk passes over a file or link the mount's `include` doesn't cover, and a push leaves one alone. */
1652
+ export const OUTSIDE_INCLUDE = "outside the mount's include";
1653
+
1654
+ /**
1655
+ * The segments of a path after its mount's name, as the workspace reads them
1656
+ * everywhere: split at `/`, and at `\` too where that's the platform's
1657
+ * separator, with no empty or `.` segment. A `..` segment stays, for the
1658
+ * parse to take back the segment before it or refuse as a climb.
1659
+ */
1660
+ export function pathSegments(path: string, platformSep: string = sep): string[] {
1661
+ return (platformSep === '\\' ? path.replaceAll('\\', '/') : path).split('/').filter((s) => s !== '' && s !== '.');
1662
+ }
1663
+
1664
+ /**
1665
+ * Whether a mount-relative path is spelled as the workspace's parsing spells
1666
+ * one: segments between single `/`, none empty, `.` or `..`, with no `\`
1667
+ * where that's the separator, or '' for the root. A walk and the watcher name
1668
+ * paths this way. An earlier version stored a path as it was written, so the
1669
+ * store can hold a key such as `a//b.txt` or `c/`, which no spelling names.
1670
+ */
1671
+ export function parsedSpelling(relativePath: string, platformSep: string = sep): boolean {
1672
+ if (relativePath === '') return true;
1673
+ const segments = pathSegments(relativePath, platformSep);
1674
+ return !segments.includes('..') && segments.join('/') === relativePath;
1675
+ }
1676
+
1677
+ /**
1678
+ * Whether a mount-relative path is inside its mount: spelled as parsing spells
1679
+ * one (parsedSpelling), and covered by the mount's `include` when it has one.
1680
+ * The mount's root, '', is inside. A store key no spelling names is outside,
1681
+ * as an entry the include doesn't cover is: nothing lists, counts, syncs or
1682
+ * writes it, and history keeps it as it was.
1683
+ */
1684
+ export function insideMount(relativePath: string, include: readonly string[] | undefined): boolean {
1685
+ if (!parsedSpelling(relativePath)) return false;
1686
+ if (include === undefined || relativePath === '') return true;
1687
+ return coveredByInclude(relativePath, include);
1688
+ }
1689
+
1690
+ /**
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.
1698
+ */
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));
1705
+ }
1706
+
1707
+ /**
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.
1716
+ */
1717
+ 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;
1727
+ }
1728
+
1398
1729
  /**
1399
1730
  * The ignore patterns a walk that names `scope` reads beneath it: a pattern
1400
1731
  * covering the scope itself, or a directory above it, doesn't apply beneath
@@ -1424,9 +1755,18 @@ export const IGNORED = 'ignored by the mount';
1424
1755
  /** The walk's reason for an entry `EDITOR_FILE` names. */
1425
1756
  const EDITOR_FILE_REASON = "an editor's swap or backup file";
1426
1757
 
1758
+ /**
1759
+ * The walk's reason for a link it doesn't enter. On a mount with an include,
1760
+ * a link the include's names don't cover is passed over with it, by design,
1761
+ * since the walk takes no name the include leaves out as a file: on a mount
1762
+ * that follows links, one that leads to a directory, and on one that
1763
+ * doesn't, every one.
1764
+ */
1765
+ const LINK_NOT_WALKED = 'a symlink, not walked';
1766
+
1427
1767
  /** Whether a reason the walk gave an entry is one it passes over by design. */
1428
1768
  export function passedOverByDesign(why: string | undefined): boolean {
1429
- return why === IGNORED || why === EDITOR_FILE_REASON || why === OTHER_MOUNT;
1769
+ return why === IGNORED || why === EDITOR_FILE_REASON || why === OTHER_MOUNT || why === OUTSIDE_INCLUDE || why === LINK_NOT_WALKED;
1430
1770
  }
1431
1771
 
1432
1772
  /** Why a pass passes over a directory that another mount nested in this one holds, and what lies beneath it. */
@@ -1548,7 +1888,7 @@ export async function walkScope(
1548
1888
  opts: { named?: boolean; resumeAfter?: string } = {},
1549
1889
  ): Promise<Walk> {
1550
1890
  const walk: Walk = {
1551
- files: new Set(), seen: new Set(), othersTaken: new Set(), dirs: new Set(), others: new Map(), complete: new Set(), missing: new Set(),
1891
+ files: new Set(), seen: new Set(), othersTaken: new Set(), dirs: new Set(), others: new Map(), otherFiles: new Set(), complete: new Set(), missing: new Set(),
1552
1892
  incomplete: [],
1553
1893
  };
1554
1894
  // A walk that isn't named passes over a scope the mount passes over by
@@ -1638,6 +1978,7 @@ export async function walkScope(
1638
1978
  // The links, ignored names and other entries those listings showed, and
1639
1979
  // the regions they gave rise to, described the directory that was there.
1640
1980
  for (const [path] of walk.others) if (beneath(path) && !walk.othersTaken.has(path)) walk.others.delete(path);
1981
+ for (const path of walk.otherFiles) if (!walk.others.has(path)) walk.otherFiles.delete(path);
1641
1982
  for (let i = walk.incomplete.length - 1; i >= 0; i--) {
1642
1983
  const region = walk.incomplete[i]!;
1643
1984
  if ((region.kind === 'symlink' || region.kind === 'ignored') && beneath(region.path)) walk.incomplete.splice(i, 1);
@@ -1685,18 +2026,55 @@ export async function walkScope(
1685
2026
  return null;
1686
2027
  }
1687
2028
  entries.sort((a, b) => compareNames(a.name, b.name));
2029
+ // A scope named through a link is listed from where the link leads, so
2030
+ // a file in it is judged where it lies as well as by its path, as
2031
+ // coversLanding judges where a path lands (judgedAt, once for the
2032
+ // listing).
2033
+ const judgedDir = view.include === undefined ? rel : await judgedAt(rootReal, dir.real, rel);
1688
2034
  const leaves: Array<[string, boolean]> = [];
1689
2035
  const subdirs: string[] = [];
1690
2036
  const others: Array<[string, string]> = [];
2037
+ const otherFiles: string[] = [];
1691
2038
  const regions: Walk['incomplete'] = [];
1692
2039
  for (const entry of entries) {
1693
2040
  const childRel = rel ? `${rel}/${entry.name}` : entry.name;
1694
- const passedOver = passedOverBecause(childRel, entry.name, ignore, view.others);
2041
+ // Another mount's directory is passed over by its real path too, so a
2042
+ // 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);
1695
2046
  if (passedOver !== undefined) {
1696
2047
  others.push([childRel, passedOver]);
1697
2048
  regions.push({ path: childRel, reason: passedOver, kind: 'ignored' });
1698
2049
  continue;
1699
2050
  }
2051
+ // A file or link the mount's include doesn't cover is outside the
2052
+ // mount, a named scope's too. A directory is walked, since a name or an
2053
+ // extension glob in include can match beneath it. Anything but a link
2054
+ // (a regular file, a FIFO, a socket or a device) is noted as a file,
2055
+ // since nothing can lie beneath it.
2056
+ if (!entry.isDirectory() && (!insideMount(childRel, view.include) || (judgedDir !== rel && !insideMount(judgedDir === '' ? entry.name : `${judgedDir}/${entry.name}`, view.include)))) {
2057
+ // The walk enters no link. A link gets the reason a mount without an
2058
+ // include gives it, a symlink the walk doesn't enter, where that's
2059
+ // what matters about it. On a mount that follows links, that's a link
2060
+ // that leads to a directory: a name or an extension glob can cover
2061
+ // what lies beneath it, as beneath a directory, and what's read
2062
+ // through it is judged by its path and where it lands, so its own
2063
+ // name doesn't put what lies there outside. On one that doesn't, it's
2064
+ // every link, since nothing reads through one there, and such a mount
2065
+ // resolves none to say where it leads. The link's name is no covered
2066
+ // file's, so the walk passes it over as it does one, rather than
2067
+ // taking it.
2068
+ if (entry.isSymbolicLink() && (!view.followSymlinks || await stat(dir.at(entry.name)).then((info) => info.isDirectory(), () => false))) {
2069
+ others.push([childRel, LINK_NOT_WALKED]);
2070
+ regions.push({ path: childRel, reason: LINK_NOT_WALKED, kind: 'ignored' });
2071
+ continue;
2072
+ }
2073
+ others.push([childRel, OUTSIDE_INCLUDE]);
2074
+ if (!entry.isSymbolicLink()) otherFiles.push(childRel);
2075
+ regions.push({ path: childRel, reason: OUTSIDE_INCLUDE, kind: 'ignored' });
2076
+ continue;
2077
+ }
1700
2078
  if (entry.isDirectory()) {
1701
2079
  subdirs.push(childRel);
1702
2080
  } else if (entry.isFile()) {
@@ -1705,7 +2083,7 @@ export async function walkScope(
1705
2083
  const symlink = entry.isSymbolicLink();
1706
2084
  leaves.push([childRel, false]);
1707
2085
  others.push([childRel, symlink ? 'a symlink' : 'not a regular file']);
1708
- if (symlink) regions.push({ path: childRel, reason: 'a symlink, not walked', kind: 'symlink' });
2086
+ if (symlink) regions.push({ path: childRel, reason: LINK_NOT_WALKED, kind: 'symlink' });
1709
2087
  }
1710
2088
  }
1711
2089
  // A listing of a directory that was moved or replaced meanwhile says
@@ -1717,6 +2095,7 @@ export async function walkScope(
1717
2095
  for (const [p, isFile] of leaves) if (isFile) walk.seen.add(p);
1718
2096
  for (const d of subdirs) walk.dirs.add(d);
1719
2097
  for (const [p, why] of others) walk.others.set(p, why);
2098
+ for (const p of otherFiles) walk.otherFiles.add(p);
1720
2099
  walk.incomplete.push(...regions);
1721
2100
  walk.complete.add(rel);
1722
2101
  const listing = { leaves, subdirs, ...dir.identity() };
@@ -1733,6 +2112,22 @@ export async function walkScope(
1733
2112
  return at === 'holds' || at === (stretch === 'after' ? 'after' : 'before');
1734
2113
  };
1735
2114
 
2115
+ // 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.
2120
+ let nestedRoots: Array<{ dev: string; ino: string }> | undefined;
2121
+ const isNestedRoot = async (dir: CheckedDir): Promise<boolean> => {
2122
+ if (!view.nested?.length) return false;
2123
+ nestedRoots ??= (await Promise.all([...new Set(view.nested.flatMap((n) => (n.real === undefined ? [] : [n.real])))].map(async (real) => {
2124
+ const at = await stat(real, { bigint: true }).catch(() => null);
2125
+ return at === null ? [] : [{ dev: String(at.dev), ino: String(at.ino) }];
2126
+ }))).flat();
2127
+ const { dev, ino } = dir.identity();
2128
+ return nestedRoots.some((r) => r.dev === dev && r.ino === ino);
2129
+ };
2130
+
1736
2131
  const visit = async (dir: CheckedDir, stretch: Stretch): Promise<void> => {
1737
2132
  visited.add(dir.rel);
1738
2133
  const listing = await list(dir);
@@ -1764,6 +2159,16 @@ export async function walkScope(
1764
2159
  // list() drops it from one that isn't.
1765
2160
  walk.missing.delete(sub);
1766
2161
  if (child.kind === 'checked') {
2162
+ if (await isNestedRoot(child.dir)) {
2163
+ // Passed over as another mount's directory, as the listing passes
2164
+ // over one it knows by path.
2165
+ walk.dirs.delete(sub);
2166
+ if (!walk.others.has(sub)) {
2167
+ walk.others.set(sub, OTHER_MOUNT);
2168
+ walk.incomplete.push({ path: sub, reason: OTHER_MOUNT, kind: 'ignored' });
2169
+ }
2170
+ continue;
2171
+ }
1767
2172
  await visit(child.dir, stretch);
1768
2173
  continue;
1769
2174
  }
@@ -1813,9 +2218,12 @@ export async function walkScope(
1813
2218
 
1814
2219
  /**
1815
2220
  * Whether the walk proves nothing exists at `path`: some ancestor directory
1816
- * is missing, or a completely listed ancestor holds no entry that could lead
1817
- * to it. Ancestors outside the walked scope are passed through, so a walk of
1818
- * `a/b` decides paths beneath `a/b` and nothing else.
2221
+ * is missing, a completely listed ancestor holds no entry that could lead
2222
+ * to it, or an ancestor is a file the mount's include doesn't cover (anything
2223
+ * but a directory or a link), which the walk passed over and which holds
2224
+ * nothing beneath it.
2225
+ * Ancestors outside the walked scope are passed through, so a walk of `a/b`
2226
+ * decides paths beneath `a/b` and nothing else.
1819
2227
  */
1820
2228
  export function provenAbsent(walk: Walk, path: string): boolean {
1821
2229
  const parts = path.split('/');
@@ -1824,6 +2232,7 @@ export function provenAbsent(walk: Walk, path: string): boolean {
1824
2232
  if (walk.missing.has(dir)) return true;
1825
2233
  const child = dir ? `${dir}/${parts[i]}` : parts[i]!;
1826
2234
  if (walk.complete.has(dir)) {
2235
+ if (i < parts.length - 1 && walk.otherFiles.has(child)) return true; // a file: nothing beneath it exists
1827
2236
  if (walk.others.has(child) || walk.seen.has(child)) return false; // present, but unproven
1828
2237
  if (i === parts.length - 1) return !walk.files.has(child) && !walk.dirs.has(child);
1829
2238
  if (!walk.dirs.has(child)) return true; // no directory there: nothing beneath can exist