@animalabs/agent-framework 0.27.0 → 0.28.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 (234) hide show
  1. package/README.md +56 -15
  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 +69 -11
  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 +4 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +3 -2
  10. package/dist/src/auto-continue.d.ts.map +1 -1
  11. package/dist/src/auto-continue.js +4 -3
  12. package/dist/src/auto-continue.js.map +1 -1
  13. package/dist/src/cut-off-round.d.ts +24 -0
  14. package/dist/src/cut-off-round.d.ts.map +1 -0
  15. package/dist/src/cut-off-round.js +87 -0
  16. package/dist/src/cut-off-round.js.map +1 -0
  17. package/dist/src/framework.d.ts +304 -89
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +2577 -678
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +99 -3
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +538 -13
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -1
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -0
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/reply-judge.d.ts +116 -0
  30. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  31. package/dist/src/gate/reply-judge.js +467 -0
  32. package/dist/src/gate/reply-judge.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +161 -0
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  36. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  37. package/dist/src/heartbeat/heartbeat.js +752 -0
  38. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  39. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  40. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  41. package/dist/src/heartbeat/reminder-time.js +107 -0
  42. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  43. package/dist/src/index.d.ts +4 -0
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +2 -0
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  48. package/dist/src/mcpl/capability-mask.js +3 -2
  49. package/dist/src/mcpl/capability-mask.js.map +1 -1
  50. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  51. package/dist/src/mcpl/channel-registry.js +2 -1
  52. package/dist/src/mcpl/channel-registry.js.map +1 -1
  53. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  54. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  55. package/dist/src/mcpl/inbound-source.js +40 -69
  56. package/dist/src/mcpl/inbound-source.js.map +1 -1
  57. package/dist/src/mcpl/server-connection.d.ts +18 -8
  58. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-connection.js +50 -10
  60. package/dist/src/mcpl/server-connection.js.map +1 -1
  61. package/dist/src/mcpl/tags.d.ts +9 -2
  62. package/dist/src/mcpl/tags.d.ts.map +1 -1
  63. package/dist/src/mcpl/tags.js +12 -2
  64. package/dist/src/mcpl/tags.js.map +1 -1
  65. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  66. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  67. package/dist/src/mcpl/tool-presence.js +134 -65
  68. package/dist/src/mcpl/tool-presence.js.map +1 -1
  69. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  70. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  71. package/dist/src/mcpl/tool-signature.js +47 -18
  72. package/dist/src/mcpl/tool-signature.js.map +1 -1
  73. package/dist/src/mcpl/toolset-host.d.ts +88 -37
  74. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  75. package/dist/src/mcpl/toolset-host.js +207 -108
  76. package/dist/src/mcpl/toolset-host.js.map +1 -1
  77. package/dist/src/mcpl/toolsets-module.d.ts +14 -11
  78. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  79. package/dist/src/mcpl/toolsets-module.js +141 -49
  80. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  81. package/dist/src/mcpl/types.d.ts +3 -1
  82. package/dist/src/mcpl/types.d.ts.map +1 -1
  83. package/dist/src/mcpl/types.js.map +1 -1
  84. package/dist/src/module-registry.d.ts +4 -1
  85. package/dist/src/module-registry.d.ts.map +1 -1
  86. package/dist/src/module-registry.js +9 -2
  87. package/dist/src/module-registry.js.map +1 -1
  88. package/dist/src/modules/history/index.d.ts.map +1 -1
  89. package/dist/src/modules/history/index.js +22 -11
  90. package/dist/src/modules/history/index.js.map +1 -1
  91. package/dist/src/modules/workspace/answers.d.ts +22 -0
  92. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/answers.js +123 -0
  94. package/dist/src/modules/workspace/answers.js.map +1 -0
  95. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  96. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  97. package/dist/src/modules/workspace/branch-intent.js +6 -2
  98. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  99. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  100. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  101. package/dist/src/modules/workspace/direct-write.js +57 -24
  102. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  103. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  104. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  106. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  107. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  108. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/edit-text.js +4 -2
  110. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/effects.js +20 -13
  113. package/dist/src/modules/workspace/effects.js.map +1 -1
  114. package/dist/src/modules/workspace/index.d.ts +62 -51
  115. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  116. package/dist/src/modules/workspace/index.js +934 -531
  117. package/dist/src/modules/workspace/index.js.map +1 -1
  118. package/dist/src/modules/workspace/observe.d.ts +75 -63
  119. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/observe.js +366 -180
  121. package/dist/src/modules/workspace/observe.js.map +1 -1
  122. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  123. package/dist/src/modules/workspace/read-page.js +7 -1
  124. package/dist/src/modules/workspace/read-page.js.map +1 -1
  125. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/reconcile.js +26 -16
  127. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  128. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  129. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/result-text.js.map +1 -1
  131. package/dist/src/modules/workspace/sync.d.ts +9 -0
  132. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  133. package/dist/src/modules/workspace/sync.js +14 -0
  134. package/dist/src/modules/workspace/sync.js.map +1 -1
  135. package/dist/src/modules/workspace/types.d.ts +96 -43
  136. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  137. package/dist/src/modules/workspace/types.js.map +1 -1
  138. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/watcher.js +8 -3
  140. package/dist/src/modules/workspace/watcher.js.map +1 -1
  141. package/dist/src/prose-drafts.d.ts +4 -3
  142. package/dist/src/prose-drafts.d.ts.map +1 -1
  143. package/dist/src/prose-drafts.js +3 -2
  144. package/dist/src/prose-drafts.js.map +1 -1
  145. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  146. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  147. package/dist/src/reasoning-loss-notice.js +50 -0
  148. package/dist/src/reasoning-loss-notice.js.map +1 -0
  149. package/dist/src/reasoning-loss.d.ts +24 -0
  150. package/dist/src/reasoning-loss.d.ts.map +1 -0
  151. package/dist/src/reasoning-loss.js +102 -0
  152. package/dist/src/reasoning-loss.js.map +1 -0
  153. package/dist/src/send-tools.d.ts +3 -2
  154. package/dist/src/send-tools.d.ts.map +1 -1
  155. package/dist/src/send-tools.js +3 -2
  156. package/dist/src/send-tools.js.map +1 -1
  157. package/dist/src/sent-rows.js +1 -1
  158. package/dist/src/thinking-drops.d.ts +4 -11
  159. package/dist/src/thinking-drops.d.ts.map +1 -1
  160. package/dist/src/thinking-drops.js +7 -14
  161. package/dist/src/thinking-drops.js.map +1 -1
  162. package/dist/src/tool-args.d.ts +12 -0
  163. package/dist/src/tool-args.d.ts.map +1 -1
  164. package/dist/src/tool-args.js +35 -1
  165. package/dist/src/tool-args.js.map +1 -1
  166. package/dist/src/tool-result-guard.d.ts +8 -4
  167. package/dist/src/tool-result-guard.d.ts.map +1 -1
  168. package/dist/src/tool-result-guard.js +23 -11
  169. package/dist/src/tool-result-guard.js.map +1 -1
  170. package/dist/src/tune-out/tools.d.ts +4 -1
  171. package/dist/src/tune-out/tools.d.ts.map +1 -1
  172. package/dist/src/types/agent.d.ts +40 -9
  173. package/dist/src/types/agent.d.ts.map +1 -1
  174. package/dist/src/types/events.d.ts +18 -8
  175. package/dist/src/types/events.d.ts.map +1 -1
  176. package/dist/src/types/framework.d.ts +15 -0
  177. package/dist/src/types/framework.d.ts.map +1 -1
  178. package/dist/src/types/module.d.ts +5 -2
  179. package/dist/src/types/module.d.ts.map +1 -1
  180. package/dist/src/types/trace.d.ts +34 -1
  181. package/dist/src/types/trace.d.ts.map +1 -1
  182. package/package.json +3 -3
  183. package/src/agent.ts +72 -11
  184. package/src/api/server.ts +4 -2
  185. package/src/auto-continue.ts +4 -3
  186. package/src/cut-off-round.ts +98 -0
  187. package/src/framework.ts +2694 -700
  188. package/src/gate/event-gate.ts +579 -15
  189. package/src/gate/index.ts +8 -0
  190. package/src/gate/reply-judge.ts +512 -0
  191. package/src/gate/types.ts +164 -1
  192. package/src/heartbeat/heartbeat.ts +804 -0
  193. package/src/heartbeat/reminder-time.ts +107 -0
  194. package/src/index.ts +4 -0
  195. package/src/mcpl/capability-mask.ts +3 -2
  196. package/src/mcpl/channel-registry.ts +2 -1
  197. package/src/mcpl/inbound-source.ts +42 -69
  198. package/src/mcpl/server-connection.ts +56 -16
  199. package/src/mcpl/tags.ts +13 -2
  200. package/src/mcpl/tool-presence.ts +133 -67
  201. package/src/mcpl/tool-signature.ts +51 -20
  202. package/src/mcpl/toolset-host.ts +197 -107
  203. package/src/mcpl/toolsets-module.ts +143 -49
  204. package/src/mcpl/types.ts +3 -1
  205. package/src/module-registry.ts +8 -2
  206. package/src/modules/history/index.ts +22 -12
  207. package/src/modules/workspace/answers.ts +133 -0
  208. package/src/modules/workspace/branch-intent.ts +10 -5
  209. package/src/modules/workspace/direct-write.ts +58 -23
  210. package/src/modules/workspace/disk-agreement.ts +52 -21
  211. package/src/modules/workspace/edit-text.ts +7 -5
  212. package/src/modules/workspace/effects.ts +21 -12
  213. package/src/modules/workspace/index.ts +927 -541
  214. package/src/modules/workspace/observe.ts +381 -177
  215. package/src/modules/workspace/read-page.ts +7 -1
  216. package/src/modules/workspace/reconcile.ts +27 -15
  217. package/src/modules/workspace/result-text.ts +2 -0
  218. package/src/modules/workspace/sync.ts +16 -0
  219. package/src/modules/workspace/types.ts +96 -43
  220. package/src/modules/workspace/watcher.ts +8 -3
  221. package/src/prose-drafts.ts +4 -3
  222. package/src/reasoning-loss-notice.ts +62 -0
  223. package/src/reasoning-loss.ts +120 -0
  224. package/src/send-tools.ts +3 -2
  225. package/src/sent-rows.ts +1 -1
  226. package/src/thinking-drops.ts +10 -16
  227. package/src/tool-args.ts +34 -1
  228. package/src/tool-result-guard.ts +26 -11
  229. package/src/tune-out/tools.ts +4 -1
  230. package/src/types/agent.ts +40 -9
  231. package/src/types/events.ts +18 -8
  232. package/src/types/framework.ts +15 -0
  233. package/src/types/module.ts +5 -2
  234. 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 { 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
 
@@ -145,11 +148,15 @@ export const RACY_WINDOW_MS = 2000;
145
148
 
146
149
  export const OUTSIDE_PARENT = 'a parent directory resolves outside the mount';
147
150
  export const NOT_FOLLOWED = 'a symlink, which this mount does not follow';
151
+ export const DIRECTORY = 'a directory';
152
+ export const NOT_REGULAR = 'not a regular file';
153
+ export const LEAVES_MOUNT = 'a symlink that leaves the mount';
154
+ export const NOT_A_DIRECTORY = 'a parent path is not a directory';
148
155
  export const ROOT_UNAVAILABLE = 'the mount root is unavailable';
149
156
  export const ROOT_REPLACED =
150
157
  'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' +
151
158
  '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';
159
+ export const CHANGED = 'it changed while it was observed';
153
160
  const CHANGED_WALK = 'the directory changed while the walk ran';
154
161
  const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
155
162
 
@@ -279,21 +286,23 @@ export async function linkLeads(root: string, rootReal: string, link: string): P
279
286
  * innermost where nested mounts nest, as #460 picks one; undefined when none
280
287
  * does.
281
288
  */
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];
289
+ export function nestedHolder<N extends NestedDir & { name: string }>(target: string, nested: readonly N[]): N | undefined {
290
+ return nestedHolders(target, nested)[0];
286
291
  }
287
292
 
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);
293
+ function nestedHolders<N extends NestedDir>(target: string, nested: readonly N[]): N[] {
294
+ const segments = target.split('/');
295
+ return mayHold(nested.filter((n) => beneathNested(segments, n)), (n) => n.dir.split('/').length, (n) => !n.guessed && surelyBeneath(segments, n.dir.split('/')));
296
+ }
297
+
298
+ function mayHold<T>(holders: readonly T[], depth: (holder: T) => number, sure: (holder: T) => boolean): T[] {
299
+ const sorted = [...holders].sort((a, b) => depth(b) - depth(a) || Number(sure(b)) - Number(sure(a)));
300
+ const surely = sorted.findIndex(sure);
301
+ return surely === -1 ? sorted : sorted.slice(0, surely + 1);
302
+ }
303
+
304
+ export function namesHolder(leads: { target: string; directory: boolean }, nested: readonly NestedMount[]): boolean {
305
+ return nestedHolders(leads.target, nested).every((holder) => namesNestedPlace(restBeneath(leads.target, holder), holder.include, leads.directory));
297
306
  }
298
307
 
299
308
  /**
@@ -312,18 +321,12 @@ export function namesNestedPlace(rest: string, include: readonly string[] | unde
312
321
  return directory || insideMount(rest, include);
313
322
  }
314
323
 
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
324
  export function withinIncludes(view: Pick<MountView, 'include' | 'nested'>, path: string): boolean {
324
325
  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);
326
+ return nestedHolders(path, view.nested ?? []).every((holder) => {
327
+ const rest = restBeneath(path, holder);
328
+ return rest === '' || namesNestedPlace(rest, holder.include, false);
329
+ });
327
330
  }
328
331
 
329
332
  /**
@@ -414,16 +417,17 @@ export async function coversLanding(view: Pick<MountView, 'include'>, rootReal:
414
417
  */
415
418
  export function linkWords(name: string, found: LinkFound, leads: LinkLeads | null, nested: readonly NestedMount[] = [], include?: readonly string[]): string {
416
419
  const spell = (rel: string) => `"${rel === '' ? name : `${name}/${rel}`}"`;
417
- if (!('link' in found)) return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${found.code})`;
420
+ if (!('link' in found)) return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${systemWords(errnoCode({ code: found.code }), found.code)})`;
418
421
  const link = `${spell(found.link)} is a symlink`;
419
422
  if (leads === null || leads.kind === 'nowhere') return `${link}, which this mount does not follow`;
420
423
  if (leads.kind === 'outside') return `${link} leading outside the mount, which this mount does not follow`;
421
424
  const holder = nestedHolder(leads.target, nested);
422
425
  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)}`;
426
+ if (!namesHolder(leads, nested)) return `${link}, which this mount does not follow`;
427
+ const rest = restBeneath(leads.target, holder);
428
+ const guess = nestedGuess(name, leads.target.split('/'), holder);
429
+ return `${link} to ${spell(leads.target)}, which this mount does not follow; it ${guess === undefined ? 'lies' : 'may lie'} in mount ` +
430
+ `"${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
431
  }
428
432
  if (!leads.directory && !insideMount(leads.target, include)) return `${link}, which this mount does not follow`;
429
433
  // Following it serves only what the include covers where it leads (judgedAt).
@@ -450,22 +454,81 @@ export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested
450
454
  return linkWords(view.name, found, await shownLeads(view.root, rootReal, found, view.include), view.nested, view.include);
451
455
  }
452
456
 
457
+ export function realSegments(path: string): string[] {
458
+ return path.split(sep).filter((segment, i) => i === 0 || segment !== '');
459
+ }
460
+
461
+ export function beneathRoot(path: string, root: string): string | undefined {
462
+ const segments = realSegments(path);
463
+ const top = realSegments(root);
464
+ return segments.length > top.length && beginsWith(segments, top) ? segments.slice(top.length).join('/') : undefined;
465
+ }
466
+
467
+ export function sameRoot(path: string, root: string): boolean {
468
+ const segments = realSegments(path);
469
+ const top = realSegments(root);
470
+ return segments.length === top.length && beginsWith(segments, top);
471
+ }
472
+
473
+ export function surelyAtRoot(path: string, root: string): boolean {
474
+ return surelyBeneath(realSegments(path), realSegments(root));
475
+ }
476
+
477
+ export function nestedRootAtReal(real: string, nested: readonly NestedMount[] = []): { mount: NestedMount; depth: number } | undefined {
478
+ return nestedRootsAtReal(real, nested)[0];
479
+ }
480
+
481
+ function nestedRootsAtReal(real: string, nested: readonly NestedMount[]): Array<{ mount: NestedMount; depth: number }> {
482
+ // realpath keeps the spelling it's given where disk folds it on Linux, as
483
+ // from a link's target, so a real root is compared as loosely as a path.
484
+ const segments = realSegments(real);
485
+ const holders = nested.flatMap((n) => {
486
+ if (n.real === undefined) return [];
487
+ const root = realSegments(n.real);
488
+ return beginsWith(segments, root) ? [{ mount: n, depth: root.length, sure: surelyBeneath(segments, root) }] : [];
489
+ });
490
+ return mayHold(holders, (h) => h.depth, (h) => h.sure);
491
+ }
492
+
453
493
  /**
454
494
  * 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
495
+ * with the path to use there and the path beneath its root (`rest`);
496
+ * undefined when none does. A path the lexical checks pass
457
497
  * (inOtherMount, the tools' holderOf) reaches another mount's directory this
458
498
  * 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.
499
+ * path canonically asks this too.
461
500
  */
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];
501
+ 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> {
502
+ const holders = nestedRootsAtReal(real, nested);
503
+ const holder = holders[0];
466
504
  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 };
505
+ const segments = realSegments(real);
506
+ // realpath keeps a link target's spelling beneath the root too, so the
507
+ // rest is read as disk spells it, for each mount's include to judge.
508
+ const rests = await Promise.all(holders.map(async (h) => {
509
+ const given = segments.slice(h.depth).join('/');
510
+ return given === '' ? '' : await diskSpelling(h.mount.real!, given);
511
+ }));
512
+ const rest = rests[0]!;
513
+ const { name } = holder.mount;
514
+ const root = realSegments(holder.mount.real!);
515
+ const found = {
516
+ name,
517
+ use: rest ? `${name}/${rest}` : name,
518
+ rest,
519
+ judgedByInclude: holders.some((h) => h.mount.include !== undefined),
520
+ names: (directory: boolean) => holders.every((h, i) => namesNestedPlace(rests[i]!, h.mount.include, directory)),
521
+ };
522
+ if (surelyBeneath(segments, root)) return found;
523
+ if (at === undefined) return { ...found, guess: null };
524
+ const top = realSegments(at.rootReal);
525
+ if (segments.length <= top.length || root.length <= top.length || !top.every((t, i) => segments[i] === t)) return { ...found, guess: null };
526
+ const within = segments.slice(top.length);
527
+ const below = root.slice(top.length);
528
+ const guess = guessFor(at.name, within, below);
529
+ const leads = guess === undefined ? [at.name, ...within].join('/') : [guess.given, ...within.slice(below.length)].join('/');
530
+ if (!surelyBeneath(root.slice(0, top.length), top)) return { ...found, guess: { ...placedGuess(at.name, within.slice(0, below.length), guess), leads } };
531
+ return { ...found, guess: guess === undefined ? null : { ...guess, leads } };
469
532
  }
470
533
 
471
534
  /**
@@ -473,8 +536,9 @@ export function nestedAtReal(real: string, nested: readonly NestedMount[] = []):
473
536
  * refused (nestedAtReal): what it leads into, and the path to use in its
474
537
  * place. Spelled as the tools take paths, from the mount's name.
475
538
  */
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`;
539
+ export function leadsInto(name: string, rel: string, holder: { name: string; use: string; guess?: NestedGuess }): string {
540
+ const verb = holder.guess === undefined ? 'leads' : 'may lead';
541
+ return `"${rel === '' ? name : `${name}/${rel}`}" ${verb} into mount "${holder.name}", nested in mount "${name}": ${nestedStep(holder, { after: ' in its place' })}`;
478
542
  }
479
543
 
480
544
  // ===========================================================================
@@ -488,9 +552,8 @@ const otherCase = (c: string): string => (c === c.toLowerCase() ? c.toUpperCase(
488
552
 
489
553
  /**
490
554
  * 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.
555
+ * letter whose own other case is the letter again. `ß`, whose upper case is
556
+ * `SS`, and dotless `ı`, whose upper case `I` lowers to `i`, stay as they are.
494
557
  */
495
558
  const flippedCase = (name: string): string =>
496
559
  [...name].map((c) => {
@@ -509,6 +572,17 @@ const folded = (name: string): string => name.normalize('NFC').toLowerCase();
509
572
  */
510
573
  export const foldedLoosely = (name: string): string => name.normalize('NFC').toLowerCase().toUpperCase().toLowerCase();
511
574
 
575
+ /**
576
+ * Linux's casefold, which ext4 and f2fs set per directory, drops
577
+ * default-ignorable code points (on every kernel but 6.12 to 6.12.4) and
578
+ * compares names decomposed, in full case folding, and HFS+ passes over some
579
+ * of those code points too. Case mapped on the decomposed name joins marks
580
+ * that come in another order around a combining ypogegrammeni (U+0345),
581
+ * whose upper case is a letter.
582
+ */
583
+ const foldedFully = (name: string): string =>
584
+ name.replace(/\p{Default_Ignorable_Code_Point}/gu, '').normalize('NFD').toLowerCase().toUpperCase().toLowerCase().normalize('NFD');
585
+
512
586
  /** What the workspace holds of a mount, as the spelling step reads it (diskSpelling). */
513
587
  export interface WorkspaceEntries {
514
588
  /**
@@ -616,18 +690,81 @@ function entryName(paths: readonly string[], prefix: string, segment: string): s
616
690
  return found;
617
691
  }
618
692
 
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)));
693
+ function sameLevel(given: string, name: string): boolean {
694
+ return given === name || (given !== '' && foldedFully(given) === foldedFully(name));
695
+ }
696
+
697
+ function beginsWith(segments: readonly string[], dir: readonly string[]): boolean {
698
+ return segments.length >= dir.length && dir.every((d, i) => sameLevel(segments[i]!, d));
699
+ }
700
+
701
+ export function beneathNested(segments: readonly string[], nested: NestedDir): boolean {
702
+ return beginsWith(segments, nested.dir.split('/'));
703
+ }
704
+
705
+ export function restBeneath(path: string, nested: NestedDir): string {
706
+ // By segments, since a name that matches can be another length, as `SS`
707
+ // is beside `ß`.
708
+ return path.split('/').slice(nested.dir.split('/').length).join('/');
709
+ }
710
+
711
+ function surelyBeneath(segments: readonly string[], dir: readonly string[]): boolean {
712
+ return segments.length >= dir.length && dir.every((d, i) => segments[i] === d);
713
+ }
714
+
715
+ function shownByCodePoints(name: string): string {
716
+ return [...name].map((c) => {
717
+ const code = c.codePointAt(0)!;
718
+ if (c === '\\') return '\\\\';
719
+ return code < 0x80 ? c : code > 0xffff ? `\\u{${code.toString(16)}}` : `\\u${code.toString(16).padStart(4, '0')}`;
720
+ }).join('');
721
+ }
722
+
723
+ export type NestedGuess = { given: string; dir: string; leads?: string } | { placed: string; dir?: string; leads?: string } | null;
724
+
725
+ function guessFor(mount: string, given: readonly string[], dir: readonly string[]): Extract<NestedGuess, { given: string }> | undefined {
726
+ if (surelyBeneath(given, dir)) return undefined;
727
+ const levels = given.slice(0, dir.length);
728
+ const plain = (a: string, b: string) => a === b || !/[^\x00-\x7f]/.test(a + b);
729
+ const shown = (name: string, other: string) => (plain(name, other) ? name : shownByCodePoints(name));
730
+ return {
731
+ given: [mount, ...levels.map((g, i) => shown(g, dir[i]!))].join('/'),
732
+ dir: [mount, ...dir.map((d, i) => shown(d, levels[i]!))].join('/'),
733
+ };
734
+ }
735
+
736
+ export function nestedGuess(mount: string, segments: readonly string[], nested: NestedDir): NestedGuess | undefined {
737
+ const dir = nested.dir.split('/');
738
+ const guess = guessFor(mount, segments, dir);
739
+ return nested.guessed ? placedGuess(mount, segments.slice(0, dir.length), guess) : guess;
740
+ }
741
+
742
+ function placedGuess(mount: string, levels: readonly string[], guess: ReturnType<typeof guessFor>): Extract<NestedGuess, { placed: string }> {
743
+ return guess === undefined ? { placed: [mount, ...levels].join('/') } : { placed: guess.given, dir: guess.dir };
744
+ }
745
+
746
+ export function nestedStep(holder: { name: string; use: string; guess?: NestedGuess }, opts: { after?: string; named?: boolean } = {}): string {
747
+ const { guess } = holder;
748
+ const after = opts.after ?? '';
749
+ if (guess === undefined) return `use "${holder.use}"${after}`;
750
+ if (guess === null) {
751
+ return `this workspace can't tell whether where it leads is that mount's directory. ` +
752
+ `If you meant that mount's directory, use "${holder.use}"${after}; if not, ask whoever runs this workspace`;
753
+ }
754
+ const leads = guess.leads === undefined ? '' : `it leads to "${guess.leads}", and `;
755
+ if ('placed' in guess) {
756
+ const check = 'ask whoever runs this workspace to check where the two mounts are configured';
757
+ const outside = guess.dir === undefined ? `"${guess.placed}"` : `"${guess.placed}" and "${guess.dir}"`;
758
+ return `${leads}this workspace can't tell whether "${guess.placed}" is mount "${holder.name}"'s directory, since the two mounts' roots are configured in ` +
759
+ `spellings it can't tell apart. If you meant that mount's directory, use "${holder.use}"${after}. ` +
760
+ (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}`);
761
+ }
762
+ const remedy = `rename one of "${guess.given}" and "${guess.dir}"`;
763
+ const own = opts.named
764
+ ? `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}`
765
+ : `If not, ask whoever runs this workspace to ${remedy}`;
766
+ return `${leads}this workspace can't tell "${guess.given}" from "${guess.dir}", ` +
767
+ `mount "${holder.name}"'s directory. If you meant that mount's directory, use "${holder.use}"${after}. ${own}`;
631
768
  }
632
769
 
633
770
  /**
@@ -825,8 +962,8 @@ export type DirCheck =
825
962
  * holds the non-directory in its place.
826
963
  */
827
964
  | { kind: 'absent'; by: string }
828
- /** Unproven either way; `outside` when it resolves outside the mount. */
829
- | { kind: 'unobserved'; reason: string; outside?: true };
965
+ /** Unproven either way; `outside` when it resolves outside the mount, `errno` when a system call failed. */
966
+ | { kind: 'unobserved'; reason: string; outside?: true; errno?: string };
830
967
 
831
968
  /** Errors meaning a directory can't be synced on this filesystem, not that syncing it failed. */
832
969
  const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
@@ -846,11 +983,16 @@ export class CheckedDir {
846
983
  private readonly lexical: string,
847
984
  private readonly dev: bigint,
848
985
  private readonly ino: bigint,
986
+ private readonly born: bigint,
849
987
  ) {}
850
988
 
851
989
  /** Which directory it is. */
852
990
  identity(): RootIdentity {
853
- return { dev: String(this.dev), ino: String(this.ino) };
991
+ return {
992
+ dev: String(this.dev),
993
+ ino: String(this.ino),
994
+ ...(this.born > 0n ? { born: String(this.born) } : {}),
995
+ };
854
996
  }
855
997
 
856
998
  /** A path that reaches `name` in this directory. */
@@ -892,11 +1034,25 @@ export class CheckedDir {
892
1034
  }
893
1035
  }
894
1036
 
1037
+ /**
1038
+ * What is at a mount root's path now, followed as the root is: for a
1039
+ * directory, undefined; for anything else, ENOTDIR; and where the path
1040
+ * can't be followed to anything, the errno its stat failed with, ENOENT
1041
+ * when nothing is there.
1042
+ */
1043
+ export async function rootAbsence(root: string): Promise<string | undefined> {
1044
+ try {
1045
+ return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
1046
+ } catch (err) {
1047
+ return errno(err);
1048
+ }
1049
+ }
1050
+
895
1051
  /** Check the directory at a canonical location, if one is there. */
896
1052
  async function checkedAt(view: MountView, rel: string, real: string): Promise<CheckedDir | null> {
897
1053
  const info = await lstat(real, { bigint: true }).catch(() => null);
898
1054
  if (!info?.isDirectory()) return null;
899
- return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino);
1055
+ return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs);
900
1056
  }
901
1057
 
902
1058
  /**
@@ -910,12 +1066,12 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
910
1066
  real = await realpath(lexical);
911
1067
  } catch (err) {
912
1068
  const code = errno(err);
913
- if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
1069
+ if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE, errno: code } : { kind: 'missing' };
914
1070
  // A link on the way that can't be resolved, such as a loop, is still a
915
1071
  // link this mount doesn't follow.
916
1072
  const found = rel === '' ? null : await linkOnTheWay(view, rel);
917
1073
  if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
918
- return { kind: 'unobserved', reason: `cannot resolve the directory (${code ?? String(err)})` };
1074
+ return { kind: 'unobserved', reason: `cannot resolve the directory (${systemWords(code, err)})`, ...(code !== undefined ? { errno: code } : {}) };
919
1075
  }
920
1076
  // A mount that doesn't follow symlinks goes through none on the way to a
921
1077
  // directory, inside the mount or out of it.
@@ -932,10 +1088,10 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
932
1088
  // mount's, which the mount neither reads nor writes. A place there that
933
1089
  // mount's include leaves out is no mount's, and holds nothing beneath it,
934
1090
  // as at any file (namesNestedPlace).
935
- const holder = nestedAtReal(real, view.nested);
1091
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
936
1092
  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' };
1093
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
1094
+ return holder.names(directory) ? { kind: 'unobserved', reason: leadsInto(view.name, rel, holder) } : { kind: 'missing' };
939
1095
  }
940
1096
  }
941
1097
  const checked = await checkedAt(view, rel, real);
@@ -961,10 +1117,10 @@ export async function locateRoot(view: MountView, rootReal: string): Promise<{ i
961
1117
  }
962
1118
 
963
1119
  /** 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 }> {
1120
+ export async function checkRoot(view: MountView, rootReal: string): Promise<{ dir: CheckedDir } | { reason: string; errno?: string }> {
965
1121
  const found = await locateDir(view, rootReal, '');
966
1122
  if (found.kind === 'checked') return { dir: found.dir };
967
- return { reason: found.kind === 'unobserved' ? found.reason : ROOT_UNAVAILABLE };
1123
+ return found.kind === 'unobserved' ? { reason: found.reason, ...(found.errno !== undefined ? { errno: found.errno } : {}) } : { reason: ROOT_UNAVAILABLE };
968
1124
  }
969
1125
 
970
1126
  /**
@@ -999,13 +1155,13 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
999
1155
  const childRel = parts.slice(0, i + 1).join('/');
1000
1156
  // Nothing is made in a nested mount's directory, its root included where
1001
1157
  // it isn't made yet, by a path that reaches it through a link.
1002
- const holder = nestedAtReal(base.at(parts[i]!), view.nested);
1158
+ const holder = await nestedAtReal(base.at(parts[i]!), view.nested, { name: view.name, rootReal });
1003
1159
  if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder) };
1004
1160
  try {
1005
1161
  await mkdir(base.at(parts[i]!));
1006
1162
  } catch (err) {
1007
1163
  const code = errno(err);
1008
- if (code !== 'EEXIST') return { kind: 'unobserved', reason: `cannot create the directory ${childRel} (${code ?? String(err)})` };
1164
+ if (code !== 'EEXIST') return { kind: 'unobserved', reason: `cannot create the directory ${childRel} (${systemWords(code, err)})`, ...(code !== undefined ? { errno: code } : {}) };
1009
1165
  }
1010
1166
  // Made, or there already (made meanwhile, or not a directory): it must
1011
1167
  // be a real directory now, never a symlink or file in its place.
@@ -1015,7 +1171,7 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
1015
1171
  // doesn't follow symlinks, is named as one.
1016
1172
  const there = view.followSymlinks ? null : await lstat(base.at(parts[i]!)).catch(() => null);
1017
1173
  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' };
1174
+ return { kind: 'unobserved', reason: NOT_A_DIRECTORY };
1019
1175
  }
1020
1176
  base = checked;
1021
1177
  }
@@ -1058,13 +1214,13 @@ export async function confirmAbsent(view: MountView, rootReal: string, rel: stri
1058
1214
  } catch (err) {
1059
1215
  const code = errno(err);
1060
1216
  if (code === 'ENOTDIR') return CHANGED;
1061
- if (code !== 'ENOENT') return `cannot stat (${code ?? String(err)})`;
1217
+ if (code !== 'ENOENT') return `cannot stat (${systemWords(code, err)})`;
1062
1218
  // Absent from a directory that, like each above it, is still the one its path names.
1063
1219
  return proven();
1064
1220
  }
1065
1221
  if (info.isDirectory()) {
1066
1222
  if (last) return CHANGED; // present after all
1067
- cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino);
1223
+ cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, info.birthtimeNs);
1068
1224
  walked.push(cur);
1069
1225
  continue;
1070
1226
  }
@@ -1173,12 +1329,12 @@ async function parentPlacement(view: MountView, rootReal: string, relativePath:
1173
1329
  const found = await linkRefusal(view, rootReal, relativePath, parent, 'parents');
1174
1330
  if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1175
1331
  if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT };
1176
- const holder = nestedAtReal(parent, view.nested);
1332
+ const holder = await nestedAtReal(parent, view.nested, { name: view.name, rootReal });
1177
1333
  if (!holder) return { inside: parent };
1178
1334
  // A place there that the nested mount's include leaves out is no mount's,
1179
1335
  // 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';
1336
+ const directory = holder.judgedByInclude && await stat(parent).then((info) => info.isDirectory(), () => false);
1337
+ return holder.names(directory) ? { reason: leadsInto(view.name, parentOf(relativePath), holder) } : 'gone';
1182
1338
  } catch (err) {
1183
1339
  const code = errno(err);
1184
1340
  if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
@@ -1186,7 +1342,7 @@ async function parentPlacement(view: MountView, rootReal: string, relativePath:
1186
1342
  // link this mount doesn't follow.
1187
1343
  const found = await linkOnTheWay(view, relativePath, 'parents');
1188
1344
  if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1189
- return { reason: `cannot resolve the parent directory (${code ?? String(err)})` };
1345
+ return { reason: `cannot resolve the parent directory (${systemWords(code, err)})` };
1190
1346
  }
1191
1347
  }
1192
1348
 
@@ -1207,7 +1363,7 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1207
1363
  } catch (err) {
1208
1364
  const code = errno(err);
1209
1365
  if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
1210
- return { kind: 'unobserved', reason: `cannot stat (${code ?? String(err)})` };
1366
+ return { kind: 'unobserved', reason: `cannot stat (${systemWords(code, err)})` };
1211
1367
  }
1212
1368
  if (info.isSymbolicLink()) {
1213
1369
  if (!view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
@@ -1215,37 +1371,37 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1215
1371
  try {
1216
1372
  target = await stat(lexical, { bigint: true });
1217
1373
  } catch (err) {
1218
- return { kind: 'unobserved', reason: `a symlink whose target cannot be read (${errno(err) ?? String(err)})` };
1374
+ return { kind: 'unobserved', reason: `a symlink whose target cannot be read (${systemWords(errno(err), err)})` };
1219
1375
  }
1220
1376
  let real: string;
1221
1377
  try {
1222
1378
  real = await realpath(lexical);
1223
1379
  } catch (err) {
1224
- return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
1380
+ return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${systemWords(errno(err), err)})` };
1225
1381
  }
1226
- if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1227
- const holder = nestedAtReal(real, view.nested);
1382
+ if (!contained(rootReal, real)) return { kind: 'unobserved', reason: LEAVES_MOUNT };
1383
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1228
1384
  // A place a nested mount's include leaves out is no mount's, so the link
1229
1385
  // leaves this one (namesNestedPlace).
1230
1386
  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' };
1387
+ return { kind: 'unobserved', reason: holder.names(target.isDirectory()) ? leadsInto(view.name, relativePath, holder) : LEAVES_MOUNT };
1232
1388
  }
1233
1389
  if (!target.isFile()) {
1234
- return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
1390
+ return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : NOT_REGULAR };
1235
1391
  }
1236
1392
  // A file this mount's own include leaves out where the link lands is no
1237
1393
  // file of this mount's (coversLanding). It's judged here, and not only
1238
1394
  // where the file is opened, since a pass takes a copy whose fingerprint
1239
1395
  // 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' };
1396
+ if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: LEAVES_MOUNT };
1241
1397
  info = target;
1242
1398
  } else if (info.isDirectory()) {
1243
- return { kind: 'unobserved', reason: 'a directory' };
1399
+ return { kind: 'unobserved', reason: DIRECTORY };
1244
1400
  } else if (!info.isFile()) {
1245
- return { kind: 'unobserved', reason: 'not a regular file' };
1401
+ return { kind: 'unobserved', reason: NOT_REGULAR };
1246
1402
  } else if (!(await coversLanding(view, rootReal, join(placement.inside, basename(lexical)), relativePath))) {
1247
1403
  // So is one reached through a link among its directories.
1248
- return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
1404
+ return { kind: 'unobserved', reason: LEAVES_MOUNT };
1249
1405
  }
1250
1406
  return { kind: 'file', size: Number(info.size), mtimeMs: Number(info.mtimeMs), mode: Number(info.mode & 0o777n), fp: statFingerprint(info) };
1251
1407
  }
@@ -1282,8 +1438,8 @@ async function openContained(
1282
1438
  const found = await linkOnTheWay(view, relativePath, 'parents');
1283
1439
  return { kind: 'unobserved', reason: found === null ? NOT_FOLLOWED : await linkReason(view, rootReal, found) };
1284
1440
  }
1285
- if (code === 'EISDIR') return { kind: 'unobserved', reason: 'a directory' };
1286
- return { kind: 'unobserved', reason: `cannot open (${code ?? String(err)})` };
1441
+ if (code === 'EISDIR') return { kind: 'unobserved', reason: DIRECTORY };
1442
+ return { kind: 'unobserved', reason: `cannot open (${systemWords(code, err)})` };
1287
1443
  }
1288
1444
  let keep = false;
1289
1445
  try {
@@ -1297,13 +1453,13 @@ async function openContained(
1297
1453
  // descriptor holds, so a link gets its own answer whatever it leads to.
1298
1454
  const found = await linkRefusal(view, rootReal, relativePath, real);
1299
1455
  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;
1456
+ if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? DIRECTORY : NOT_REGULAR };
1457
+ const leaves = view.followSymlinks ? LEAVES_MOUNT : OUTSIDE_PARENT;
1302
1458
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: leaves };
1303
- const holder = nestedAtReal(real, view.nested);
1459
+ const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1304
1460
  // A place a nested mount's include leaves out is no mount's. Reached
1305
1461
  // 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 };
1462
+ if (holder) return { kind: 'unobserved', reason: holder.names(false) ? leadsInto(view.name, relativePath, holder) : view.followSymlinks ? leaves : NOT_FOLLOWED };
1307
1463
  // So is a file this mount's own include leaves out (coversLanding).
1308
1464
  if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: view.followSymlinks ? leaves : NOT_FOLLOWED };
1309
1465
  const named = await stat(real, { bigint: true }).catch(() => null);
@@ -1325,7 +1481,7 @@ async function openContained(
1325
1481
  } catch (err) {
1326
1482
  const code = errno(err);
1327
1483
  if (code === 'ENOENT') return absence(view, rootReal, relativePath);
1328
- return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
1484
+ return { kind: 'unobserved', reason: `cannot read (${systemWords(code, err)})` };
1329
1485
  } finally {
1330
1486
  if (!keep) await handle.close();
1331
1487
  }
@@ -1423,7 +1579,7 @@ export async function readPath(
1423
1579
  } catch (err) {
1424
1580
  const code = errno(err);
1425
1581
  if (code === 'ENOENT') return absence(view, rootReal, relativePath);
1426
- return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
1582
+ return { kind: 'unobserved', reason: `cannot read (${systemWords(code, err)})` };
1427
1583
  } finally {
1428
1584
  await handle.close();
1429
1585
  }
@@ -1483,7 +1639,7 @@ export async function readBytes(
1483
1639
  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
1640
  return { kind: 'bytes', size: bytes.byteLength, bytes, head: bytes.subarray(0, HEAD_BYTES) };
1485
1641
  } catch (err) {
1486
- return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
1642
+ return { kind: 'unobserved', reason: `cannot read (${systemWords(errno(err), err)})` };
1487
1643
  } finally {
1488
1644
  await handle.close();
1489
1645
  }
@@ -1507,7 +1663,7 @@ export async function readHead(
1507
1663
  const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
1508
1664
  return { kind: 'head', size: Number(info.size), mtimeMs: Number(info.mtimeMs), head: head.subarray(0, bytesRead) };
1509
1665
  } catch (err) {
1510
- return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
1666
+ return { kind: 'unobserved', reason: `cannot read (${systemWords(errno(err), err)})` };
1511
1667
  } finally {
1512
1668
  await handle.close();
1513
1669
  }
@@ -1636,14 +1792,14 @@ function subtreeAgainst(dir: string, at: string): 'before' | 'holds' | 'after' {
1636
1792
  }
1637
1793
 
1638
1794
  /**
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.
1795
+ * Whether a mount's ignore list covers a mount-relative path: the path itself,
1796
+ * a directory or not, or any directory above it, as the walk's own test reads
1797
+ * each entry by its path and kind.
1642
1798
  */
1643
- export function coveredByIgnore(relativePath: string, patterns: string[]): boolean {
1799
+ export function coveredByIgnore(relativePath: string, patterns: readonly string[], directory: boolean): boolean {
1644
1800
  const segments = relativePath.split('/').filter(Boolean);
1645
1801
  for (let i = 0; i < segments.length; i++) {
1646
- if (isIgnored(segments.slice(0, i + 1).join('/'), segments[i]!, patterns)) return true;
1802
+ if (isIgnored(segments.slice(0, i + 1).join('/'), patterns, i < segments.length - 1 || directory)) return true;
1647
1803
  }
1648
1804
  return false;
1649
1805
  }
@@ -1688,42 +1844,89 @@ export function insideMount(relativePath: string, include: readonly string[] | u
1688
1844
  }
1689
1845
 
1690
1846
  /**
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.
1847
+ * A pattern of `ignore` or `include`, read as gitignore reads it: its
1848
+ * segments, each a name or a glob with `*` and `?` within it; whether it's a
1849
+ * path from the mount's root (a leading or inner `/`, or a trailing `/**`)
1850
+ * rather than a name at any depth; and whether it matches only directories (a
1851
+ * trailing `/` or `/**`).
1698
1852
  */
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));
1853
+ type Pattern = { segments: Array<string | RegExp>; anchored: boolean; directory: boolean };
1854
+
1855
+ const readings = new Map<string, Pattern | string>();
1856
+
1857
+ function readPattern(pattern: string): Pattern | string {
1858
+ let reading = readings.get(pattern);
1859
+ if (reading === undefined) {
1860
+ reading = parsePattern(pattern);
1861
+ readings.set(pattern, reading);
1862
+ }
1863
+ return reading;
1864
+ }
1865
+
1866
+ function parsePattern(pattern: string): Pattern | string {
1867
+ if (pattern.startsWith('!')) return 'a leading "!" makes an exception in a .gitignore, and this list has none, so leave it out';
1868
+ if (pattern.startsWith('#')) return 'a leading "#" marks a comment in a .gitignore, so leave comments out of the list';
1869
+ 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';
1870
+ if (pattern.endsWith(' ')) return 'a .gitignore drops trailing spaces, so remove them';
1871
+ const anyDepth = pattern.startsWith('**/');
1872
+ let body = anyDepth ? pattern.slice(3) : pattern;
1873
+ const beneath = body.endsWith('/**');
1874
+ const directory = beneath || body.endsWith('/');
1875
+ body = body.slice(0, beneath ? -3 : directory ? -1 : undefined);
1876
+ const anchored = (beneath && !anyDepth) || body.includes('/');
1877
+ if (body.startsWith('/') && !anyDepth) body = body.slice(1);
1878
+ const segments = body.split('/');
1879
+ if (segments.some((s) => s === '' || s === '.' || s === '..')) return 'it has an empty, "." or ".." segment, which names no entry';
1880
+ if (body.includes('**') || (anyDepth && anchored)) return '"**" is read only as a leading "**/" before one name, or a trailing "/**"';
1881
+ const glob = (s: string): string | RegExp => /[*?]/.test(s)
1882
+ ? new RegExp(`^${s.replace(/[.+^${}()|[\]*?]/g, (c) => (c === '*' ? '.*' : c === '?' ? '.' : `\\${c}`))}$`, 'su')
1883
+ : s;
1884
+ return { segments: segments.map(glob), anchored, directory };
1885
+ }
1886
+
1887
+ function nameMatches(segment: string | RegExp, name: string): boolean {
1888
+ return typeof segment === 'string' ? segment === name : segment.test(name);
1889
+ }
1890
+
1891
+ function matchesEntry(pattern: Pattern, segments: readonly string[], directory: boolean): boolean {
1892
+ if (pattern.directory && !directory) return false;
1893
+ if (!pattern.anchored) return nameMatches(pattern.segments[0]!, segments[segments.length - 1]!);
1894
+ return segments.length === pattern.segments.length && pattern.segments.every((s, i) => nameMatches(s, segments[i]!));
1895
+ }
1896
+
1897
+ export function patternRefusal(pattern: unknown, list: 'ignore' | 'include'): string | undefined {
1898
+ if (typeof pattern !== 'string') return "it isn't a string";
1899
+ const reading = readPattern(pattern);
1900
+ if (typeof reading === 'string') return reading;
1901
+ if (list === 'ignore' && pattern.includes('[')) {
1902
+ return '"[" starts a character class in a .gitignore, which this list doesn\'t read, so write each name out, with "?" for a "[" in it';
1903
+ }
1904
+ return undefined;
1905
+ }
1906
+
1907
+ export function directoryAt(path: string, followSymlinks: boolean): Promise<boolean | undefined> {
1908
+ if (!followSymlinks) return lstat(path).then((s) => s.isDirectory(), () => undefined);
1909
+ return stat(path).then((s) => s.isDirectory(), () => lstat(path).then(() => false, () => undefined));
1705
1910
  }
1706
1911
 
1707
1912
  /**
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.
1913
+ * Whether a mount's include covers a mount-relative path, read as a file: a
1914
+ * pattern that matches only directories covers what lies beneath a directory
1915
+ * it matches, and any other covers a file by its own name or path, and
1916
+ * nothing beneath a directory it matches. So `gate.json` covers `x/gate.json`
1917
+ * and not `x/gate.json/run.sh`, and a subtree is named with `dir/` or
1918
+ * `dir/**`. A directory is no file, so it needs no covering: where one is a
1919
+ * scope, refusesAsOutside (index.ts) decides.
1716
1920
  */
1717
1921
  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;
1922
+ const segments = relativePath.split('/');
1923
+ return include.some((p) => {
1924
+ const pattern = readPattern(p);
1925
+ if (typeof pattern === 'string') return false;
1926
+ if (!pattern.directory) return matchesEntry(pattern, segments, false);
1927
+ for (let k = 1; k < segments.length; k++) if (matchesEntry(pattern, segments.slice(0, k), true)) return true;
1928
+ return false;
1929
+ });
1727
1930
  }
1728
1931
 
1729
1932
  /**
@@ -1732,7 +1935,7 @@ function coveredByInclude(relativePath: string, include: readonly string[]): boo
1732
1935
  * it (#276); one covering only something inside it does.
1733
1936
  */
1734
1937
  export function ignoreBeneath(scope: string, patterns: string[]): string[] {
1735
- return patterns.filter((pattern) => !coveredByIgnore(scope, [pattern]));
1938
+ return patterns.filter((pattern) => !coveredByIgnore(scope, [pattern], true));
1736
1939
  }
1737
1940
 
1738
1941
  /**
@@ -1781,34 +1984,34 @@ export const OTHER_MOUNT = "another mount's directory";
1781
1984
  * Every nested-mount check (a pass's, a walk's, a push's, grep's) asks this.
1782
1985
  */
1783
1986
  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 + '/'));
1987
+ const segments = posix.normalize(relativePath).replace(/\/+$/, '').split('/');
1988
+ return others.some((dir) => beneathNested(segments, { dir }));
1786
1989
  }
1787
1990
 
1788
1991
  /**
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.
1992
+ * Why a walk passes over an entry by design, by its mount-relative path, its
1993
+ * name and whether it's a directory: another mount's directory, the ignore
1994
+ * list, or an editor's file name. Undefined when none covers it.
1792
1995
  */
1793
- function passedOverBecause(relativePath: string, name: string, patterns: string[], others: readonly string[]): string | undefined {
1996
+ function passedOverBecause(relativePath: string, name: string, patterns: string[], others: readonly string[], directory: boolean): string | undefined {
1794
1997
  if (inOtherMount(relativePath, others)) return OTHER_MOUNT;
1795
- if (isIgnored(relativePath, name, patterns)) return IGNORED;
1998
+ if (isIgnored(relativePath, patterns, directory)) return IGNORED;
1796
1999
  if (EDITOR_FILE.test(name)) return EDITOR_FILE_REASON;
1797
2000
  return undefined;
1798
2001
  }
1799
2002
 
1800
2003
  /**
1801
2004
  * 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.
2005
+ * path, a directory or not: the path itself or a directory above it is passed
2006
+ * over by design, as the walk reads each entry by its path, name and kind. A
2007
+ * path in another mount's directory is that mount's, whatever else covers a
2008
+ * directory above it. Undefined when nothing covers it.
1806
2009
  */
1807
- export function passedOverAt(relativePath: string, patterns: string[], others: readonly string[]): string | undefined {
2010
+ export function passedOverAt(relativePath: string, patterns: string[], others: readonly string[], directory: boolean): string | undefined {
1808
2011
  if (inOtherMount(relativePath, others)) return OTHER_MOUNT;
1809
2012
  const segments = relativePath.split('/').filter(Boolean);
1810
2013
  for (let i = 0; i < segments.length; i++) {
1811
- const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others);
2014
+ const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others, i < segments.length - 1 || directory);
1812
2015
  if (why !== undefined) return why;
1813
2016
  }
1814
2017
  return undefined;
@@ -1824,24 +2027,19 @@ export function passedOverBetween(scope: string, relativePath: string, patterns:
1824
2027
  const segments = relativePath.split('/').filter(Boolean);
1825
2028
  const from = scope === '' ? 0 : scope.split('/').filter(Boolean).length;
1826
2029
  for (let i = from; i < segments.length - 1; i++) {
1827
- const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others);
2030
+ const why = passedOverBecause(segments.slice(0, i + 1).join('/'), segments[i]!, patterns, others, true);
1828
2031
  if (why !== undefined) return why;
1829
2032
  }
1830
2033
  return undefined;
1831
2034
  }
1832
2035
 
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;
2036
+ /** Whether an ignore pattern matches the entry at a mount-relative path, a directory or not, itself. */
2037
+ function isIgnored(relativePath: string, patterns: readonly string[], directory: boolean): boolean {
2038
+ const segments = relativePath.split('/');
2039
+ return patterns.some((p) => {
2040
+ const pattern = readPattern(p);
2041
+ return typeof pattern !== 'string' && matchesEntry(pattern, segments, directory);
2042
+ });
1845
2043
  }
1846
2044
 
1847
2045
  /**
@@ -1855,12 +2053,12 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
1855
2053
  try {
1856
2054
  info = await lstat(real, { bigint: true });
1857
2055
  } catch (err) {
1858
- if (errno(err) !== 'ENOENT') return { kind: 'unobserved', reason: `cannot stat (${errno(err) ?? String(err)})` };
2056
+ if (errno(err) !== 'ENOENT') return { kind: 'unobserved', reason: `cannot stat (${systemWords(errno(err), err)})` };
1859
2057
  const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
1860
2058
  return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
1861
2059
  }
1862
2060
  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) };
2061
+ return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs) };
1864
2062
  }
1865
2063
 
1866
2064
  /**
@@ -1893,12 +2091,11 @@ export async function walkScope(
1893
2091
  };
1894
2092
  // A walk that isn't named passes over a scope the mount passes over by
1895
2093
  // 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.
2094
+ // over its entry. So a listing, glob or grep there never starts tracking
2095
+ // what the mount leaves out, and a stored copy beneath it is unobserved, as
2096
+ // beneath any region passed over by design. Its region says how to take
2097
+ // what's there in: a sync that names it, whose scope is walked (#276),
2098
+ // below.
1902
2099
  // Another mount's directory is passed over by every walk, named or not,
1903
2100
  // and no sync of this mount takes it in, so its region gives no advice.
1904
2101
  if (scope !== '' && inOtherMount(scope, view.others)) {
@@ -1907,7 +2104,10 @@ export async function walkScope(
1907
2104
  return walk;
1908
2105
  }
1909
2106
  if (!opts.named && scope !== '') {
1910
- const why = passedOverAt(scope, view.ignore, view.others);
2107
+ let why = passedOverAt(scope, view.ignore, view.others, true);
2108
+ if (why !== undefined && passedOverAt(scope, view.ignore, view.others, false) === undefined) {
2109
+ if ((await directoryAt(join(rootReal, scope), view.followSymlinks)) === false) why = undefined;
2110
+ }
1911
2111
  if (why !== undefined) {
1912
2112
  walk.others.set(scope, why);
1913
2113
  walk.incomplete.push({ path: scope, reason: `${why}: a sync that names this path takes in what's there, editors' files aside`, kind: 'ignored' });
@@ -2040,9 +2240,13 @@ export async function walkScope(
2040
2240
  const childRel = rel ? `${rel}/${entry.name}` : entry.name;
2041
2241
  // Another mount's directory is passed over by its real path too, so a
2042
2242
  // 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);
2243
+ // none of that mount's names (nestedRootAtReal).
2244
+ let passedOver = passedOverBecause(childRel, entry.name, ignore, view.others, entry.isDirectory())
2245
+ ?? (!entry.isSymbolicLink() && nestedRootAtReal(dir.at(entry.name), view.nested) !== undefined ? OTHER_MOUNT : undefined);
2246
+ if (passedOver === undefined && view.followSymlinks && entry.isSymbolicLink()) {
2247
+ const asDirectory = passedOverBecause(childRel, entry.name, ignore, view.others, true);
2248
+ if (asDirectory !== undefined && (await directoryAt(dir.at(entry.name), true)) === true) passedOver = asDirectory;
2249
+ }
2046
2250
  if (passedOver !== undefined) {
2047
2251
  others.push([childRel, passedOver]);
2048
2252
  regions.push({ path: childRel, reason: passedOver, kind: 'ignored' });
@@ -2082,7 +2286,7 @@ export async function walkScope(
2082
2286
  } else {
2083
2287
  const symlink = entry.isSymbolicLink();
2084
2288
  leaves.push([childRel, false]);
2085
- others.push([childRel, symlink ? 'a symlink' : 'not a regular file']);
2289
+ others.push([childRel, symlink ? 'a symlink' : NOT_REGULAR]);
2086
2290
  if (symlink) regions.push({ path: childRel, reason: LINK_NOT_WALKED, kind: 'symlink' });
2087
2291
  }
2088
2292
  }
@@ -2113,10 +2317,10 @@ export async function walkScope(
2113
2317
  };
2114
2318
 
2115
2319
  // 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.
2320
+ // first descends: a directory the walk reaches by a path that no
2321
+ // comparison of names places in the nested mount's directory, as through a
2322
+ // bind mount, or on a disk whose folding goes past the comparison's, is
2323
+ // still another mount's directory.
2120
2324
  let nestedRoots: Array<{ dev: string; ino: string }> | undefined;
2121
2325
  const isNestedRoot = async (dir: CheckedDir): Promise<boolean> => {
2122
2326
  if (!view.nested?.length) return false;