@vibe-agent-toolkit/utils 0.2.0-rc.6 → 0.2.0-rc.8

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 (193) hide show
  1. package/README.md +43 -9
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/crawl-timing.d.ts +3 -3
  5. package/dist/crawl-timing.js +3 -3
  6. package/dist/dirent-kind.d.ts +83 -0
  7. package/dist/dirent-kind.d.ts.map +1 -0
  8. package/dist/dirent-kind.js +129 -0
  9. package/dist/dirent-kind.js.map +1 -0
  10. package/dist/entrypoint.d.ts +54 -0
  11. package/dist/entrypoint.d.ts.map +1 -0
  12. package/dist/entrypoint.js +71 -0
  13. package/dist/entrypoint.js.map +1 -0
  14. package/dist/env-flag.d.ts +46 -0
  15. package/dist/env-flag.d.ts.map +1 -0
  16. package/dist/env-flag.js +57 -0
  17. package/dist/env-flag.js.map +1 -0
  18. package/dist/errors/errno.d.ts +51 -0
  19. package/dist/errors/errno.d.ts.map +1 -0
  20. package/dist/errors/errno.js +112 -0
  21. package/dist/errors/errno.js.map +1 -0
  22. package/dist/errors/vat-error.d.ts +60 -0
  23. package/dist/errors/vat-error.d.ts.map +1 -0
  24. package/dist/errors/vat-error.js +86 -0
  25. package/dist/errors/vat-error.js.map +1 -0
  26. package/dist/file-crawler.d.ts +22 -0
  27. package/dist/file-crawler.d.ts.map +1 -1
  28. package/dist/file-crawler.js +83 -22
  29. package/dist/file-crawler.js.map +1 -1
  30. package/dist/fs/file-hash.d.ts.map +1 -1
  31. package/dist/fs/file-hash.js +0 -1
  32. package/dist/fs/file-hash.js.map +1 -1
  33. package/dist/fs-utils.d.ts +422 -217
  34. package/dist/fs-utils.d.ts.map +1 -1
  35. package/dist/fs-utils.js +535 -283
  36. package/dist/fs-utils.js.map +1 -1
  37. package/dist/fs.d.ts +3 -2
  38. package/dist/fs.d.ts.map +1 -1
  39. package/dist/fs.js +5 -7
  40. package/dist/fs.js.map +1 -1
  41. package/dist/git-run.d.ts +1 -1
  42. package/dist/git-run.js +1 -1
  43. package/dist/git-tracker.d.ts +41 -2
  44. package/dist/git-tracker.d.ts.map +1 -1
  45. package/dist/git-tracker.js +84 -19
  46. package/dist/git-tracker.js.map +1 -1
  47. package/dist/git-utils.d.ts +47 -2
  48. package/dist/git-utils.d.ts.map +1 -1
  49. package/dist/git-utils.js +123 -20
  50. package/dist/git-utils.js.map +1 -1
  51. package/dist/git.d.ts +1 -0
  52. package/dist/git.d.ts.map +1 -1
  53. package/dist/git.js.map +1 -1
  54. package/dist/gitignore-checker.d.ts.map +1 -1
  55. package/dist/gitignore-checker.js +9 -3
  56. package/dist/gitignore-checker.js.map +1 -1
  57. package/dist/index.d.ts +10 -4
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +48 -22
  60. package/dist/index.js.map +1 -1
  61. package/dist/listing-refusal.d.ts +112 -0
  62. package/dist/listing-refusal.d.ts.map +1 -0
  63. package/dist/listing-refusal.js +105 -0
  64. package/dist/listing-refusal.js.map +1 -0
  65. package/dist/path-containment.d.ts +64 -0
  66. package/dist/path-containment.d.ts.map +1 -0
  67. package/dist/path-containment.js +111 -0
  68. package/dist/path-containment.js.map +1 -0
  69. package/dist/path-core.d.ts +77 -3
  70. package/dist/path-core.d.ts.map +1 -1
  71. package/dist/path-core.js +96 -6
  72. package/dist/path-core.js.map +1 -1
  73. package/dist/path-utils.d.ts.map +1 -1
  74. package/dist/path-utils.js +42 -47
  75. package/dist/path-utils.js.map +1 -1
  76. package/dist/path.d.ts +1 -1
  77. package/dist/path.d.ts.map +1 -1
  78. package/dist/path.js +1 -1
  79. package/dist/path.js.map +1 -1
  80. package/dist/process.d.ts +7 -0
  81. package/dist/process.d.ts.map +1 -1
  82. package/dist/process.js +7 -0
  83. package/dist/process.js.map +1 -1
  84. package/dist/project-utils.d.ts.map +1 -1
  85. package/dist/project-utils.js +9 -6
  86. package/dist/project-utils.js.map +1 -1
  87. package/dist/safe-exec.d.ts +2 -1
  88. package/dist/safe-exec.d.ts.map +1 -1
  89. package/dist/safe-exec.js +26 -8
  90. package/dist/safe-exec.js.map +1 -1
  91. package/dist/skill-targets.d.ts +2 -2
  92. package/dist/skill-targets.js +2 -2
  93. package/dist/skill-test/auth-resolver.d.ts +10 -2
  94. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  95. package/dist/skill-test/auth-resolver.js +26 -11
  96. package/dist/skill-test/auth-resolver.js.map +1 -1
  97. package/dist/skill-test/spawn-claude.d.ts +2 -1
  98. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  99. package/dist/skill-test/spawn-claude.js +20 -3
  100. package/dist/skill-test/spawn-claude.js.map +1 -1
  101. package/dist/skill-test/transcript.d.ts.map +1 -1
  102. package/dist/skill-test/transcript.js +18 -17
  103. package/dist/skill-test/transcript.js.map +1 -1
  104. package/dist/stdio-blocking.js +13 -14
  105. package/dist/stdio-blocking.js.map +1 -1
  106. package/dist/test-helpers.d.ts +56 -193
  107. package/dist/test-helpers.d.ts.map +1 -1
  108. package/dist/test-helpers.js +115 -284
  109. package/dist/test-helpers.js.map +1 -1
  110. package/dist/testing/executables.d.ts +32 -0
  111. package/dist/testing/executables.d.ts.map +1 -0
  112. package/dist/testing/executables.js +70 -0
  113. package/dist/testing/executables.js.map +1 -0
  114. package/dist/testing/hostile-tree.d.ts +79 -0
  115. package/dist/testing/hostile-tree.d.ts.map +1 -0
  116. package/dist/testing/hostile-tree.js +164 -0
  117. package/dist/testing/hostile-tree.js.map +1 -0
  118. package/dist/testing/platform-gates.d.ts +31 -0
  119. package/dist/testing/platform-gates.d.ts.map +1 -0
  120. package/dist/testing/platform-gates.js +31 -0
  121. package/dist/testing/platform-gates.js.map +1 -0
  122. package/dist/testing/temp-dir.d.ts +255 -0
  123. package/dist/testing/temp-dir.d.ts.map +1 -0
  124. package/dist/testing/temp-dir.js +363 -0
  125. package/dist/testing/temp-dir.js.map +1 -0
  126. package/dist/testing.d.ts +21 -0
  127. package/dist/testing.d.ts.map +1 -1
  128. package/dist/testing.js +27 -1
  129. package/dist/testing.js.map +1 -1
  130. package/dist/text-content.d.ts.map +1 -1
  131. package/dist/text-content.js +7 -1
  132. package/dist/text-content.js.map +1 -1
  133. package/dist/text-file.d.ts.map +1 -1
  134. package/dist/text-file.js +0 -2
  135. package/dist/text-file.js.map +1 -1
  136. package/dist/timing-dump.d.ts +1 -1
  137. package/dist/timing-dump.d.ts.map +1 -1
  138. package/dist/timing-dump.js +1 -2
  139. package/dist/timing-dump.js.map +1 -1
  140. package/dist/windows-shell.d.ts +0 -1
  141. package/dist/windows-shell.d.ts.map +1 -1
  142. package/dist/windows-shell.js +0 -1
  143. package/dist/windows-shell.js.map +1 -1
  144. package/dist/zod-introspection.js +2 -2
  145. package/dist/zod-introspection.js.map +1 -1
  146. package/eslint/README.md +132 -60
  147. package/eslint/index.cjs +111 -147
  148. package/eslint/index.d.cts +14 -14
  149. package/eslint/rules/callee-name.cjs +23 -0
  150. package/eslint/rules/commands-import-boundary.cjs +148 -0
  151. package/eslint/rules/dead-import.cjs +1 -1
  152. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  153. package/eslint/rules/eslint-rule-factory.cjs +5 -2
  154. package/eslint/rules/exempt-path-matcher.cjs +1 -1
  155. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  156. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  157. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  158. package/eslint/rules/no-bare-symlink-in-tests.cjs +16 -3
  159. package/eslint/rules/no-blind-catch.cjs +171 -0
  160. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  161. package/eslint/rules/no-decaying-referent.cjs +216 -0
  162. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  163. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  164. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  165. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  166. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  167. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  168. package/eslint/rules/no-hardcoded-path-split.cjs +7 -2
  169. package/eslint/rules/no-io-in-unit-tier.cjs +128 -0
  170. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  171. package/eslint/rules/no-manual-path-normalize.cjs +7 -2
  172. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  173. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  174. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  175. package/eslint/rules/no-path-startswith.cjs +7 -2
  176. package/eslint/rules/no-process-exit-in-phase.cjs +12 -2
  177. package/eslint/rules/no-raw-node-path.cjs +563 -0
  178. package/eslint/rules/no-raw-text-decode.cjs +10 -1
  179. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  180. package/eslint/rules/no-self-package-import.cjs +13 -2
  181. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  182. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  183. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  184. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  185. package/eslint/rules/no-version-literal.cjs +151 -0
  186. package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
  187. package/eslint/rules/require-justified-skip.cjs +8 -2
  188. package/eslint/rules/safe-import.cjs +1 -1
  189. package/package.json +5 -10
  190. package/eslint/rules/no-path-join.cjs +0 -14
  191. package/eslint/rules/no-path-relative.cjs +0 -14
  192. package/eslint/rules/no-path-resolve.cjs +0 -14
  193. package/eslint/rules/path-function-rule-factory.cjs +0 -496
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Read an environment variable as a boolean.
3
+ *
4
+ * 🔑 **Returns `undefined` for anything it does not recognise, and never
5
+ * guesses.** That is the whole point: the caller — not this function — decides
6
+ * what an unreadable value means, because only the caller knows which way is
7
+ * safe. A token-fetching kill switch wants "deny"; a cache toggle wants
8
+ * "leave the cache on". A parser that folded the unknown case into `false`
9
+ * would have made that choice for both of them.
10
+ *
11
+ * 🚨 **Why this exists at all.** Three switches were each a comparison against
12
+ * the literal string `'0'`, so `=false` turned none of them off — measured for
13
+ * `VAT_LINKAUTH_ALLOW_COMMAND`, which *still spawned subprocesses*. Every
14
+ * spelling a human reaches for (`false`, `no`, `off`, `FALSE`, a value with a
15
+ * stray space) failed open. A switch whose off position is one exact string is
16
+ * not a switch.
17
+ *
18
+ * 📍 **Why it lives in `utils`.** It was written next to its first consumer in
19
+ * `resources`, on the rule that utils takes a utility when a SECOND package
20
+ * needs it rather than speculatively. That second package arrived:
21
+ * `packages/cli`'s `projectionStoreSelected()` reads the same `VAT_CACHE` the
22
+ * `resources` parse cache does, and two independent readings of one variable is
23
+ * the defect, not the fix. The three consumers today:
24
+ *
25
+ * | Caller | Variable | Reads `undefined` as | Why |
26
+ * |---|---|---|---|
27
+ * | `link-auth/resolve-token.ts` | `VAT_LINKAUTH_ALLOW_COMMAND` | **deny** | gates a capability — fail closed |
28
+ * | `resources/parse-cache.ts` | `VAT_CACHE` | cache stays on | gates a cache — an unreadable value must not silently change behaviour |
29
+ * | `cli/utils/projection-store.ts` | `VAT_CACHE` | not a veto | same variable, same reading, one implementation |
30
+ *
31
+ * Same parser, different safe sides, each chosen at its own call site. That is
32
+ * the contract; do not move a default in here.
33
+ */
34
+ const TRUE_SPELLINGS = new Set(['1', 'true', 'yes', 'y', 'on']);
35
+ const FALSE_SPELLINGS = new Set(['0', 'false', 'no', 'n', 'off']);
36
+ /**
37
+ * Parse an env value as a boolean.
38
+ *
39
+ * Case-insensitive and surrounding whitespace is trimmed, because a value that
40
+ * arrived through a shell, a CI YAML block, or a `.env` file routinely carries
41
+ * both. Everything else — including the empty string, which is what an unset
42
+ * shell variable expands to — returns `undefined`.
43
+ *
44
+ * @param raw - The raw env value, or `undefined` when the variable is unset
45
+ * @returns `true` / `false` for a recognised spelling; `undefined` otherwise
46
+ */
47
+ export function parseEnvBoolean(raw) {
48
+ if (raw === undefined)
49
+ return undefined;
50
+ const normalized = raw.trim().toLowerCase();
51
+ if (TRUE_SPELLINGS.has(normalized))
52
+ return true;
53
+ if (FALSE_SPELLINGS.has(normalized))
54
+ return false;
55
+ return undefined;
56
+ }
57
+ //# sourceMappingURL=env-flag.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env-flag.js","sourceRoot":"","sources":["../src/env-flag.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;AACrF,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAEvF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,GAAuB;IACrD,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5C,IAAI,cAAc,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,IAAI,eAAe,CAAC,GAAG,CAAC,UAAU,CAAC;QAAE,OAAO,KAAK,CAAC;IAClD,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Errno predicates — the two questions every catch site asks of an OS error,
3
+ * answered by the `code` on the error or down its `cause` chain.
4
+ *
5
+ * ⚠️ This module imports NOTHING. It is the leaf that `fs-utils.ts`,
6
+ * `path-utils.ts`, `dirent-kind.ts`, `path-containment.ts` and the rest of the
7
+ * package share; it lived in `fs-utils.ts` until that put three of them in an
8
+ * import cycle (`fs-utils → path-utils → fs-utils` and two more), and a leaf
9
+ * is the one place a shared predicate cannot re-open one. (The cycle was once
10
+ * blamed for a platform-dependent knip verdict on these two names; breaking it
11
+ * changed nothing there — the cause was a missing knip entry, see
12
+ * `docs/contributing/traps.md`, "A subpath module's re-exports flap by platform".)
13
+ */
14
+ /**
15
+ * Whether `error` is the filesystem refusing a path rather than a bug.
16
+ *
17
+ * Deliberately NOT `error instanceof Error`: the point of every caller is to
18
+ * degrade on a hostile tree, and a `TypeError` from a validator is not that.
19
+ * Treating one as environmental turns a real defect into a warning about
20
+ * whichever file it happened on — which makes a tool quietest exactly when it is
21
+ * most wrong.
22
+ *
23
+ * Walks `cause`, because the errno is routinely re-wrapped on its way up. The CLI
24
+ * config loader turns a read failure into `new Error('Failed to load config: …')`;
25
+ * without following the chain the predicate answered "not a filesystem error" for
26
+ * a plain `EACCES`, and an unreadable config aborted a whole `vat audit` run. Any
27
+ * layer that adds context to an OS error defeats a `code`-only check, so the check
28
+ * cannot be `code`-only.
29
+ */
30
+ export declare function isFilesystemAccessError(error: unknown): boolean;
31
+ /**
32
+ * Whether `error` means **there is nothing at this path** — `ENOENT`, or
33
+ * `ENOTDIR` for a path whose component turned out to be a file — and nothing
34
+ * else.
35
+ *
36
+ * This is the narrowing a `try { stat(p) } catch { return null }` is rewritten
37
+ * to under the `no-blind-catch` lint rule: the sentinel stands for *absent*,
38
+ * so only an absence may produce it; a refusal (`EACCES`, `EPERM`, `ELOOP`) or
39
+ * a bug (`TypeError`) is rethrown and stays loud.
40
+ *
41
+ * ⚠️ Deliberately NOT {@link isFilesystemAccessError}. That predicate answers
42
+ * "is this the environment's fault?" and to answer it groups `ENOENT` with
43
+ * `EACCES` — the exact conflation that once turned an unreadable directory
44
+ * into an empty one. The two questions have two predicates on purpose; see
45
+ * also {@link listingFailure}, which makes the same split for `readdir`.
46
+ *
47
+ * Walks `cause` for the same reason its sibling does: the errno is routinely
48
+ * re-wrapped on its way up.
49
+ */
50
+ export declare function isPathAbsentError(error: unknown): boolean;
51
+ //# sourceMappingURL=errno.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errno.d.ts","sourceRoot":"","sources":["../../src/errors/errno.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAwCH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEzD"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Errno predicates — the two questions every catch site asks of an OS error,
3
+ * answered by the `code` on the error or down its `cause` chain.
4
+ *
5
+ * ⚠️ This module imports NOTHING. It is the leaf that `fs-utils.ts`,
6
+ * `path-utils.ts`, `dirent-kind.ts`, `path-containment.ts` and the rest of the
7
+ * package share; it lived in `fs-utils.ts` until that put three of them in an
8
+ * import cycle (`fs-utils → path-utils → fs-utils` and two more), and a leaf
9
+ * is the one place a shared predicate cannot re-open one. (The cycle was once
10
+ * blamed for a platform-dependent knip verdict on these two names; breaking it
11
+ * changed nothing there — the cause was a missing knip entry, see
12
+ * `docs/contributing/traps.md`, "A subpath module's re-exports flap by platform".)
13
+ */
14
+ /**
15
+ * Errno codes meaning "the filesystem refused this path", as opposed to a defect
16
+ * in our own code.
17
+ *
18
+ * Shared because two lanes need the same answer and must not drift: `vat audit`
19
+ * decides whether to degrade a scan over a tree it does not own, and the skill
20
+ * packager decides whether a `files:` match is copyable. A second, independently
21
+ * written list is how those two come to disagree about what counts as the
22
+ * environment's fault.
23
+ *
24
+ * The set is deliberately broad. An earlier, "conservative" version omitted
25
+ * `ENOTSUP` — the errno of the very issue this was written for — along with
26
+ * `EEXIST`, which an ordinary two-entry `files:` config reaches with no
27
+ * permissions involved at all. Both escaped raw. Every code here means the OS
28
+ * refused a syscall on a path; none of them can be produced by a type error or a
29
+ * logic bug in our own code, which is the only distinction the callers need.
30
+ *
31
+ * `EIO` and `EBUSY` are included even though they can indicate failing hardware:
32
+ * neither caller *swallows* anything, each reports the path and the OS message,
33
+ * so a dying disk surfaces once per affected path. Aborting the run instead would
34
+ * report less. `ENOENT` is included because a bulk scan races real filesystems —
35
+ * an entry listed by `readdir` can be gone by the time it is opened.
36
+ */
37
+ const FILESYSTEM_ACCESS_ERRNOS = new Set([
38
+ // Permission and ownership
39
+ 'EACCES', 'EPERM', 'EROFS',
40
+ // Presence and shape
41
+ 'ENOENT', 'EEXIST', 'ENOTDIR', 'EISDIR', 'ENOTEMPTY', 'ELOOP', 'ENAMETOOLONG',
42
+ // Capability of the object or filesystem
43
+ 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV', 'ETXTBSY', 'EINVAL',
44
+ // Resource exhaustion and transient device state
45
+ 'ENOSPC', 'EDQUOT', 'EMFILE', 'ENFILE', 'EIO', 'EBUSY', 'EAGAIN',
46
+ // Network filesystems
47
+ 'ESTALE', 'ETIMEDOUT', 'EHOSTDOWN', 'ENETDOWN',
48
+ // Windows surfaces this for reparse points and some network paths
49
+ 'UNKNOWN',
50
+ ]);
51
+ /**
52
+ * Whether `error` is the filesystem refusing a path rather than a bug.
53
+ *
54
+ * Deliberately NOT `error instanceof Error`: the point of every caller is to
55
+ * degrade on a hostile tree, and a `TypeError` from a validator is not that.
56
+ * Treating one as environmental turns a real defect into a warning about
57
+ * whichever file it happened on — which makes a tool quietest exactly when it is
58
+ * most wrong.
59
+ *
60
+ * Walks `cause`, because the errno is routinely re-wrapped on its way up. The CLI
61
+ * config loader turns a read failure into `new Error('Failed to load config: …')`;
62
+ * without following the chain the predicate answered "not a filesystem error" for
63
+ * a plain `EACCES`, and an unreadable config aborted a whole `vat audit` run. Any
64
+ * layer that adds context to an OS error defeats a `code`-only check, so the check
65
+ * cannot be `code`-only.
66
+ */
67
+ export function isFilesystemAccessError(error) {
68
+ return hasErrnoCode(error, (code) => FILESYSTEM_ACCESS_ERRNOS.has(code));
69
+ }
70
+ /**
71
+ * Whether `error` means **there is nothing at this path** — `ENOENT`, or
72
+ * `ENOTDIR` for a path whose component turned out to be a file — and nothing
73
+ * else.
74
+ *
75
+ * This is the narrowing a `try { stat(p) } catch { return null }` is rewritten
76
+ * to under the `no-blind-catch` lint rule: the sentinel stands for *absent*,
77
+ * so only an absence may produce it; a refusal (`EACCES`, `EPERM`, `ELOOP`) or
78
+ * a bug (`TypeError`) is rethrown and stays loud.
79
+ *
80
+ * ⚠️ Deliberately NOT {@link isFilesystemAccessError}. That predicate answers
81
+ * "is this the environment's fault?" and to answer it groups `ENOENT` with
82
+ * `EACCES` — the exact conflation that once turned an unreadable directory
83
+ * into an empty one. The two questions have two predicates on purpose; see
84
+ * also {@link listingFailure}, which makes the same split for `readdir`.
85
+ *
86
+ * Walks `cause` for the same reason its sibling does: the errno is routinely
87
+ * re-wrapped on its way up.
88
+ */
89
+ export function isPathAbsentError(error) {
90
+ return hasErrnoCode(error, (code) => code === 'ENOENT' || code === 'ENOTDIR');
91
+ }
92
+ /**
93
+ * Whether any string `code` on `error` or down its `cause` chain satisfies
94
+ * `accept`. Bounded: a malformed `cause` chain must not become an infinite
95
+ * loop inside an error path, which is the worst place to hang.
96
+ */
97
+ function hasErrnoCode(error, accept) {
98
+ for (let current = error, depth = 0; depth < 10; depth++) {
99
+ if (typeof current !== 'object' || current === null)
100
+ return false;
101
+ if ('code' in current) {
102
+ const { code } = current;
103
+ if (typeof code === 'string' && accept(code))
104
+ return true;
105
+ }
106
+ if (!('cause' in current))
107
+ return false;
108
+ current = current.cause;
109
+ }
110
+ return false;
111
+ }
112
+ //# sourceMappingURL=errno.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errno.js","sourceRoot":"","sources":["../../src/errors/errno.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,wBAAwB,GAAwB,IAAI,GAAG,CAAC;IAC5D,2BAA2B;IAC3B,QAAQ,EAAE,OAAO,EAAE,OAAO;IAC1B,qBAAqB;IACrB,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,cAAc;IAC7E,yCAAyC;IACzC,SAAS,EAAE,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ;IACrD,iDAAiD;IACjD,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ;IAChE,sBAAsB;IACtB,QAAQ,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU;IAC9C,kEAAkE;IAClE,SAAS;CACV,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,uBAAuB,CAAC,KAAc;IACpD,OAAO,YAAY,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,wBAAwB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;AAC3E,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,OAAO,YAAY,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,CAAC,CAAC;AAChF,CAAC;AAED;;;;GAIG;AACH,SAAS,YAAY,CAAC,KAAc,EAAE,MAAiC;IACrE,KAAK,IAAI,OAAO,GAAY,KAAK,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,EAAE,EAAE,KAAK,EAAE,EAAE,CAAC;QAClE,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QAClE,IAAI,MAAM,IAAI,OAAO,EAAE,CAAC;YACtB,MAAM,EAAE,IAAI,EAAE,GAAG,OAA4B,CAAC;YAC9C,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAC;QAC5D,CAAC;QACD,IAAI,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC;YAAE,OAAO,KAAK,CAAC;QACxC,OAAO,GAAI,OAA8B,CAAC,KAAK,CAAC;IAClD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The base class of every error VAT throws on purpose.
3
+ *
4
+ * `code` is the one field a catch block may dispatch on. A message is prose
5
+ * for a human and changes whenever the prose improves; a code is a contract
6
+ * and changes only when the meaning does. Three packages used to recognise a
7
+ * root-escape by `error.message.startsWith('safePath.joinUnderRoot:')`, which
8
+ * held exactly until someone reworded the sentence — the ESLint restriction
9
+ * on `.message.includes(…)` exists so nobody writes the fourth.
10
+ *
11
+ * The brand is a registry symbol rather than the class identity because the
12
+ * class identity is not stable across the one boundary VAT crosses every day:
13
+ * a `dist` copy of a class never `instanceof`-matches a `src` instance, and
14
+ * `packages/cli` imports the same modules from both during tests.
15
+ * `Symbol.for` is realm-global, so {@link isVatError} answers the same on both
16
+ * sides.
17
+ */
18
+ declare const VAT_ERROR_BRAND: unique symbol;
19
+ /** An error VAT threw on purpose, with a stable machine-readable `code`. */
20
+ export declare class VatError extends Error {
21
+ /** Stable identity for dispatch — `SCREAMING_SNAKE`, never read from prose. */
22
+ readonly code: string;
23
+ readonly [VAT_ERROR_BRAND] = true;
24
+ /**
25
+ * @param code - The stable identity a catch block dispatches on
26
+ * @param message - What went wrong, for a human
27
+ * @param options - `cause`, as on a native Error
28
+ */
29
+ constructor(code: string, message: string, options?: ErrorOptions);
30
+ toString(): string;
31
+ }
32
+ /**
33
+ * Whether `error` is a VAT error — optionally one carrying exactly `code`.
34
+ *
35
+ * Reads the brand and the `code` field, never the prototype chain, so an
36
+ * instance produced by another copy of this module (a `dist` build beside a
37
+ * `src` import) still answers yes. A foreign error that happens to carry a
38
+ * `code` — every `node:fs` errno does — answers no: the brand is the claim.
39
+ *
40
+ * @param error - Anything a catch block received
41
+ * @param code - When given, the exact code required
42
+ */
43
+ export declare function isVatError(error: unknown, code?: string): error is VatError;
44
+ /**
45
+ * Prefix an error's message IN PLACE, exactly once per error object.
46
+ *
47
+ * In place rather than re-wrapped because the error's class is what its
48
+ * catch blocks dispatch on (`GradingNonceError`, `RateLimitSignal`, …), and a
49
+ * wrapper would flatten every class into one. Once, because a retried item or
50
+ * a cached error object passes through the same seam more than once and must
51
+ * not accumulate prefixes. "Once" is remembered per object, not detected by
52
+ * re-reading the message — the previous check was `message.startsWith(prefix)`,
53
+ * which is a dispatch on prose by another name.
54
+ *
55
+ * @param error - Anything a catch block received; a non-Error is left alone
56
+ * @param prefix - Text to put before the message
57
+ */
58
+ export declare function prefixMessageOnce(error: unknown, prefix: string): void;
59
+ export {};
60
+ //# sourceMappingURL=vat-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vat-error.d.ts","sourceRoot":"","sources":["../../src/errors/vat-error.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,QAAA,MAAM,eAAe,EAAE,OAAO,MAAgC,CAAC;AAE/D,4EAA4E;AAC5E,qBAAa,QAAS,SAAQ,KAAK;IACjC,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,CAAC,eAAe,CAAC,QAAQ;IAElC;;;;OAIG;gBACS,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;IAMxD,QAAQ,IAAI,MAAM;CAG5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,IAAI,QAAQ,CAM3E;AAUD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAItE"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The base class of every error VAT throws on purpose.
3
+ *
4
+ * `code` is the one field a catch block may dispatch on. A message is prose
5
+ * for a human and changes whenever the prose improves; a code is a contract
6
+ * and changes only when the meaning does. Three packages used to recognise a
7
+ * root-escape by `error.message.startsWith('safePath.joinUnderRoot:')`, which
8
+ * held exactly until someone reworded the sentence — the ESLint restriction
9
+ * on `.message.includes(…)` exists so nobody writes the fourth.
10
+ *
11
+ * The brand is a registry symbol rather than the class identity because the
12
+ * class identity is not stable across the one boundary VAT crosses every day:
13
+ * a `dist` copy of a class never `instanceof`-matches a `src` instance, and
14
+ * `packages/cli` imports the same modules from both during tests.
15
+ * `Symbol.for` is realm-global, so {@link isVatError} answers the same on both
16
+ * sides.
17
+ */
18
+ const VAT_ERROR_BRAND = Symbol.for('vat.error');
19
+ /** An error VAT threw on purpose, with a stable machine-readable `code`. */
20
+ export class VatError extends Error {
21
+ /** Stable identity for dispatch — `SCREAMING_SNAKE`, never read from prose. */
22
+ code;
23
+ [VAT_ERROR_BRAND] = true;
24
+ /**
25
+ * @param code - The stable identity a catch block dispatches on
26
+ * @param message - What went wrong, for a human
27
+ * @param options - `cause`, as on a native Error
28
+ */
29
+ constructor(code, message, options) {
30
+ super(message, options);
31
+ this.name = new.target.name;
32
+ this.code = code;
33
+ }
34
+ toString() {
35
+ return `${this.name} [${this.code}]: ${this.message}`;
36
+ }
37
+ }
38
+ /**
39
+ * Whether `error` is a VAT error — optionally one carrying exactly `code`.
40
+ *
41
+ * Reads the brand and the `code` field, never the prototype chain, so an
42
+ * instance produced by another copy of this module (a `dist` build beside a
43
+ * `src` import) still answers yes. A foreign error that happens to carry a
44
+ * `code` — every `node:fs` errno does — answers no: the brand is the claim.
45
+ *
46
+ * @param error - Anything a catch block received
47
+ * @param code - When given, the exact code required
48
+ */
49
+ export function isVatError(error, code) {
50
+ if (typeof error !== 'object' || error === null)
51
+ return false;
52
+ if (!(VAT_ERROR_BRAND in error) || error[VAT_ERROR_BRAND] !== true)
53
+ return false;
54
+ const declared = error.code;
55
+ if (typeof declared !== 'string')
56
+ return false;
57
+ return code === undefined || declared === code;
58
+ }
59
+ /**
60
+ * Marks an error {@link prefixMessageOnce} has already prefixed. A registry
61
+ * symbol on the error itself rather than a module-local `WeakSet`: under the
62
+ * same src/dist duplication that justifies the `Symbol.for` brand above, a
63
+ * per-module set would make "once" mean "once per copy of this module".
64
+ */
65
+ const PREFIXED = Symbol.for('vat.error.prefixed');
66
+ /**
67
+ * Prefix an error's message IN PLACE, exactly once per error object.
68
+ *
69
+ * In place rather than re-wrapped because the error's class is what its
70
+ * catch blocks dispatch on (`GradingNonceError`, `RateLimitSignal`, …), and a
71
+ * wrapper would flatten every class into one. Once, because a retried item or
72
+ * a cached error object passes through the same seam more than once and must
73
+ * not accumulate prefixes. "Once" is remembered per object, not detected by
74
+ * re-reading the message — the previous check was `message.startsWith(prefix)`,
75
+ * which is a dispatch on prose by another name.
76
+ *
77
+ * @param error - Anything a catch block received; a non-Error is left alone
78
+ * @param prefix - Text to put before the message
79
+ */
80
+ export function prefixMessageOnce(error, prefix) {
81
+ if (!(error instanceof Error) || PREFIXED in error)
82
+ return;
83
+ Object.defineProperty(error, PREFIXED, { value: true, enumerable: false });
84
+ error.message = `${prefix}${error.message}`;
85
+ }
86
+ //# sourceMappingURL=vat-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vat-error.js","sourceRoot":"","sources":["../../src/errors/vat-error.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,eAAe,GAAkB,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;AAE/D,4EAA4E;AAC5E,MAAM,OAAO,QAAS,SAAQ,KAAK;IACjC,+EAA+E;IACtE,IAAI,CAAS;IACb,CAAC,eAAe,CAAC,GAAG,IAAI,CAAC;IAElC;;;;OAIG;IACH,YAAY,IAAY,EAAE,OAAe,EAAE,OAAsB;QAC/D,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;IAEQ,QAAQ;QACf,OAAO,GAAG,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACxD,CAAC;CACF;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CAAC,KAAc,EAAE,IAAa;IACtD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC9D,IAAI,CAAC,CAAC,eAAe,IAAI,KAAK,CAAC,IAAI,KAAK,CAAC,eAAe,CAAC,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACjF,MAAM,QAAQ,GAAI,KAA4B,CAAC,IAAI,CAAC;IACpD,IAAI,OAAO,QAAQ,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC/C,OAAO,IAAI,KAAK,SAAS,IAAI,QAAQ,KAAK,IAAI,CAAC;AACjD,CAAC;AAED;;;;;GAKG;AACH,MAAM,QAAQ,GAAkB,MAAM,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;AAEjE;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAc,EAAE,MAAc;IAC9D,IAAI,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC,IAAI,QAAQ,IAAI,KAAK;QAAE,OAAO;IAC3D,MAAM,CAAC,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC;IAC3E,KAAK,CAAC,OAAO,GAAG,GAAG,MAAM,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC;AAC9C,CAAC"}
@@ -1,3 +1,6 @@
1
+ import { type UnreadablePolicy } from './listing-refusal.js';
2
+ export type { DirectoryRefusal } from './fs-utils.js';
3
+ export { DirectoryListingRefusedError, type RefuseListingContext, refusedListingMessage, settleRefusal, type UnreadablePolicy, } from './listing-refusal.js';
1
4
  /**
2
5
  * Options for directory crawling
3
6
  */
@@ -35,6 +38,25 @@ export interface CrawlOptions {
35
38
  * committed yet"; `respectGitignore: false` is not, and costs the whole walk.
36
39
  */
37
40
  includeUntracked?: boolean;
41
+ /**
42
+ * What to do with a directory the walk could not LIST. Required, with no
43
+ * default: see {@link UnreadablePolicy} for the two answers and why the
44
+ * caller — not the crawler — is the one who knows which is honest here.
45
+ *
46
+ * A directory that VANISHED between being enumerated and being listed
47
+ * (`ENOENT` / `ENOTDIR`) is not a refusal: it is no longer in the population
48
+ * and is skipped without a call.
49
+ *
50
+ * **Both routes settle it.** The `git ls-files` route walks the working tree
51
+ * whenever {@link CrawlOptions.includeUntracked} is set (`--others`), and a
52
+ * directory git could not open arrives here too — read off git's stderr,
53
+ * where it is the only trace: git exits 0 and lists fewer files. A directory
54
+ * the `exclude` patterns drop is not reported on either route, because the
55
+ * walk never lists one. The tracked-only listing (`includeUntracked: false`)
56
+ * opens no directory at all — the index names every member — so it has no
57
+ * gap to report.
58
+ */
59
+ unreadable: UnreadablePolicy;
38
60
  }
39
61
  /**
40
62
  * Directories no VAT crawl should ever walk into. THE canonical list — any lane
@@ -1 +1 @@
1
- {"version":3,"file":"file-crawler.d.ts","sourceRoot":"","sources":["../src/file-crawler.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,6CAA6C;IAC7C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,iBAAiB,sJAQpB,CAAC;AAEX;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,yBAA0B,CAAC;AAI1D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAQnC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAE7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,EAAE,CAgQlE"}
1
+ {"version":3,"file":"file-crawler.d.ts","sourceRoot":"","sources":["../src/file-crawler.ts"],"names":[],"mappings":"AAUA,OAAO,EAA0C,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAGrG,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAItD,OAAO,EACL,4BAA4B,EAC5B,KAAK,oBAAoB,EACzB,qBAAqB,EACrB,aAAa,EACb,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC;AAE9B;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,6CAA6C;IAC7C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;;;;;;;;;;;OAiBG;IACH,UAAU,EAAE,gBAAgB,CAAC;CAC9B;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,iBAAiB,sJAQpB,CAAC;AAEX;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,yBAA0B,CAAC;AAI1D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAQnC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAE7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,EAAE,CAuTlE"}
@@ -1,7 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import picomatch from 'picomatch';
3
+ import { directoryRefusalFor, listingFailure, } from './fs-utils.js';
3
4
  import { gitFindRoot, gitLsFiles } from './git-utils.js';
5
+ import { requireUnreadablePolicy, settleRefusal } from './listing-refusal.js';
4
6
  import { toForwardSlash, safePath } from './path-utils.js';
7
+ // The refusal vocabulary lives in `listing-refusal.ts` so `git-utils.ts` can
8
+ // share it without importing this module (which imports that one). Re-exported
9
+ // here because `./crawl` is where every caller of the walk already looks.
10
+ export { DirectoryListingRefusedError, refusedListingMessage, settleRefusal, } from './listing-refusal.js';
5
11
  /**
6
12
  * Glob options shared by every pattern this module compiles.
7
13
  *
@@ -136,17 +142,35 @@ export async function crawlDirectory(options) {
136
142
  * });
137
143
  */
138
144
  export function crawlDirectorySync(options) {
139
- const { baseDir, include = ['**/*'], exclude = DEFAULT_EXCLUDE, followSymlinks = false, absolute = true, filesOnly = true, respectGitignore = true, includeUntracked = false, } = options;
145
+ const { baseDir, include = ['**/*'], exclude = DEFAULT_EXCLUDE, followSymlinks = false, absolute = true, filesOnly = true, respectGitignore = true, includeUntracked = false, unreadable, } = options;
146
+ requireUnreadablePolicy(unreadable, 'crawlDirectory');
140
147
  const picoOptions = PICOMATCH_OPTIONS;
141
148
  // Resolve base directory to absolute path
142
149
  const resolvedBaseDir = safePath.resolve(baseDir);
150
+ // Compiled once, ahead of the route choice, because BOTH routes ask it: the
151
+ // walk before it lists a directory, the git route before it reports one git
152
+ // could not list. One matcher is what keeps "is this directory excluded?"
153
+ // answered the same way on both sides of the fork.
154
+ const isExcluded = exclude.length > 0 ? picomatch(exclude, picoOptions) : () => false;
155
+ /**
156
+ * Check if a path should be excluded based on patterns
157
+ */
158
+ function shouldExclude(normalizedPath) {
159
+ // Check explicit exclude patterns
160
+ return isExcluded(normalizedPath) || isExcluded(normalizedPath + '/');
161
+ }
162
+ /**
163
+ * A refused listing surfaces under the caller's policy — thrown, or handed
164
+ * over. Never a silent skip, on either route.
165
+ */
166
+ function raiseRefusal(refusal) {
167
+ settleRefusal(unreadable, refusal);
168
+ }
143
169
  // Ensure base directory exists
144
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- baseDir is from controlled config, not user input
145
170
  if (!fs.existsSync(resolvedBaseDir)) {
146
171
  throw new Error(`Base directory does not exist: ${resolvedBaseDir}`);
147
172
  }
148
173
  // Ensure base directory is actually a directory
149
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved path validated above
150
174
  const baseStat = fs.statSync(resolvedBaseDir);
151
175
  if (!baseStat.isDirectory()) {
152
176
  throw new Error(`Base path is not a directory: ${resolvedBaseDir}`);
@@ -161,6 +185,24 @@ export function crawlDirectorySync(options) {
161
185
  const gitFiles = gitLsFiles({
162
186
  cwd: resolvedBaseDir,
163
187
  includeUntracked,
188
+ // The SAME decision the walk makes, in the same order: a directory
189
+ // outside this crawl's base or dropped by `exclude` is never listed by
190
+ // the walk, so a refusal on it is not this crawl's gap; anything else
191
+ // is, and goes where the walk's would go.
192
+ // `degrade` from git's side only: the refusal is filtered to this
193
+ // crawl's territory and then settled under the CALLER's policy, which
194
+ // may well be `refuse`.
195
+ unreadable: {
196
+ degrade: (refusal) => {
197
+ // Strictly beneath the base: a repository is often an ancestor of
198
+ // the crawl, and git names every refusal in the whole worktree.
199
+ if (!toForwardSlash(refusal.directory).startsWith(`${toForwardSlash(resolvedBaseDir)}/`))
200
+ return;
201
+ if (shouldExclude(toForwardSlash(safePath.relative(resolvedBaseDir, refusal.directory))))
202
+ return;
203
+ raiseRefusal(refusal);
204
+ },
205
+ },
164
206
  });
165
207
  if (gitFiles !== null) {
166
208
  // ⚠️ KNOWN DIVERGENCE: this branch ignores `followSymlinks`.
@@ -199,7 +241,6 @@ export function crawlDirectorySync(options) {
199
241
  // Fall back to manual directory crawling (not in git repo or git ls-files failed)
200
242
  // Compile glob patterns using picomatch
201
243
  const isIncluded = picomatch(include, picoOptions);
202
- const isExcluded = exclude.length > 0 ? picomatch(exclude, picoOptions) : () => false;
203
244
  const results = [];
204
245
  /**
205
246
  * Real paths of directories already walked, maintained ONLY when following
@@ -218,13 +259,6 @@ export function crawlDirectorySync(options) {
218
259
  * directory cannot be reached twice, so the default path pays no `realpath`.
219
260
  */
220
261
  const visitedRealDirs = new Set();
221
- /**
222
- * Check if a path should be excluded based on patterns
223
- */
224
- function shouldExclude(normalizedPath) {
225
- // Check explicit exclude patterns
226
- return isExcluded(normalizedPath) || isExcluded(normalizedPath + '/');
227
- }
228
262
  /**
229
263
  * Add a path to results if it matches include patterns
230
264
  */
@@ -233,24 +267,49 @@ export function crawlDirectorySync(options) {
233
267
  results.push(absolute ? fullPath : relativePath);
234
268
  }
235
269
  }
270
+ /**
271
+ * What a failed `readdirSync` / `statSync` means for the walk.
272
+ *
273
+ * Absence (`ENOENT` / `ENOTDIR`) is an entry that vanished between being
274
+ * enumerated by its parent and being asked about itself — not in the
275
+ * population, nothing to report. Anything else is a refusal, and a refusal
276
+ * is a GAP: handed to the caller if it asked, thrown otherwise. Never a
277
+ * silent `return` — that is the shorter list this walk used to hand back.
278
+ *
279
+ * @param error - What the filesystem threw
280
+ * @param target - The directory (or link) it was asked about
281
+ * @returns True when the entry is simply gone and the walk should move on
282
+ */
283
+ function reportOrSkip(error, target) {
284
+ const listing = listingFailure(error);
285
+ if (listing.outcome === 'absent')
286
+ return true;
287
+ if (listing.outcome === 'unreadable')
288
+ raiseRefusal(directoryRefusalFor(listing, target));
289
+ return false;
290
+ }
236
291
  /**
237
292
  * Record a directory as walked, reporting whether it had already been seen.
238
293
  *
239
294
  * Identity is `realpathSync.native`, not the traversal path: two names for
240
295
  * one directory must collide here or the alias is enumerated twice. A
241
- * directory whose real path cannot be read is treated as already-walked —
242
- * refusing to descend into something we cannot identify is the safe side of
243
- * a guard whose whole job is bounding traversal.
296
+ * directory whose real path cannot be read is not descended into — refusing
297
+ * to walk something we cannot identify is the safe side of a guard whose
298
+ * whole job is bounding traversal — but it is NOT silently "already walked":
299
+ * a directory that vanished is skipped like any other absence, and one the
300
+ * OS refused to canonicalise is a gap, surfaced under the caller's policy
301
+ * exactly as a refused listing is (see `reportOrSkip`).
244
302
  *
245
303
  * @param dir - Directory about to be walked
246
- * @returns True when this directory has been walked before
304
+ * @returns True when this directory must not be walked (seen before, gone, or refused)
247
305
  */
248
306
  function alreadyWalked(dir) {
249
307
  let realPath;
250
308
  try {
251
309
  realPath = fs.realpathSync.native(dir);
252
310
  }
253
- catch {
311
+ catch (error) {
312
+ reportOrSkip(error, dir);
254
313
  return true;
255
314
  }
256
315
  if (visitedRealDirs.has(realPath)) {
@@ -269,11 +328,12 @@ export function crawlDirectorySync(options) {
269
328
  // Resolve symlink and check if it's a directory or file
270
329
  let targetStat;
271
330
  try {
272
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path constructed from validated baseDir + entries
273
331
  targetStat = fs.statSync(fullPath);
274
332
  }
275
- catch {
276
- // Skip broken symlinks
333
+ catch (error) {
334
+ // A broken symlink is absence and is skipped; a target the OS refused to
335
+ // stat is a gap, and goes the same way a refused listing does.
336
+ reportOrSkip(error, fullPath);
277
337
  return;
278
338
  }
279
339
  if (targetStat.isDirectory()) {
@@ -309,11 +369,12 @@ export function crawlDirectorySync(options) {
309
369
  }
310
370
  let entries;
311
371
  try {
312
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- path constructed from validated baseDir, recursively walking
313
372
  entries = fs.readdirSync(currentDir, { withFileTypes: true });
314
373
  }
315
- catch {
316
- // Skip directories we don't have permission to read
374
+ catch (error) {
375
+ // 🚨 Not a silent skip. A directory that refused to be listed is a gap in
376
+ // the population this walk defines — see `reportOrSkip`.
377
+ reportOrSkip(error, currentDir);
317
378
  return;
318
379
  }
319
380
  // Two passes, not one: `readdirSync` order is filesystem-defined (see