@vibe-agent-toolkit/utils 0.1.42 → 0.2.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/README.md +81 -19
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/compare-code-units.d.ts +28 -0
  5. package/dist/compare-code-units.d.ts.map +1 -0
  6. package/dist/compare-code-units.js +33 -0
  7. package/dist/compare-code-units.js.map +1 -0
  8. package/dist/crawl-timing.d.ts +657 -0
  9. package/dist/crawl-timing.d.ts.map +1 -0
  10. package/dist/crawl-timing.js +765 -0
  11. package/dist/crawl-timing.js.map +1 -0
  12. package/dist/crawl.d.ts +2 -4
  13. package/dist/crawl.d.ts.map +1 -1
  14. package/dist/crawl.js +2 -4
  15. package/dist/crawl.js.map +1 -1
  16. package/dist/dirent-kind.d.ts +83 -0
  17. package/dist/dirent-kind.d.ts.map +1 -0
  18. package/dist/dirent-kind.js +129 -0
  19. package/dist/dirent-kind.js.map +1 -0
  20. package/dist/entrypoint.d.ts +54 -0
  21. package/dist/entrypoint.d.ts.map +1 -0
  22. package/dist/entrypoint.js +71 -0
  23. package/dist/entrypoint.js.map +1 -0
  24. package/dist/env-flag.d.ts +51 -0
  25. package/dist/env-flag.d.ts.map +1 -0
  26. package/dist/env-flag.js +62 -0
  27. package/dist/env-flag.js.map +1 -0
  28. package/dist/errors/errno.d.ts +51 -0
  29. package/dist/errors/errno.d.ts.map +1 -0
  30. package/dist/errors/errno.js +112 -0
  31. package/dist/errors/errno.js.map +1 -0
  32. package/dist/errors/vat-error.d.ts +60 -0
  33. package/dist/errors/vat-error.d.ts.map +1 -0
  34. package/dist/errors/vat-error.js +86 -0
  35. package/dist/errors/vat-error.js.map +1 -0
  36. package/dist/file-crawler.d.ts +60 -0
  37. package/dist/file-crawler.d.ts.map +1 -1
  38. package/dist/file-crawler.js +214 -24
  39. package/dist/file-crawler.js.map +1 -1
  40. package/dist/fs/file-hash.d.ts.map +1 -1
  41. package/dist/fs/file-hash.js +0 -1
  42. package/dist/fs/file-hash.js.map +1 -1
  43. package/dist/fs-utils.d.ts +628 -38
  44. package/dist/fs-utils.d.ts.map +1 -1
  45. package/dist/fs-utils.js +770 -71
  46. package/dist/fs-utils.js.map +1 -1
  47. package/dist/fs.d.ts +5 -1
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +9 -1
  50. package/dist/fs.js.map +1 -1
  51. package/dist/git-root-cache.d.ts +44 -0
  52. package/dist/git-root-cache.d.ts.map +1 -0
  53. package/dist/git-root-cache.js +68 -0
  54. package/dist/git-root-cache.js.map +1 -0
  55. package/dist/git-run.d.ts +167 -0
  56. package/dist/git-run.d.ts.map +1 -0
  57. package/dist/git-run.js +154 -0
  58. package/dist/git-run.js.map +1 -0
  59. package/dist/git-snapshot.d.ts +199 -0
  60. package/dist/git-snapshot.d.ts.map +1 -0
  61. package/dist/git-snapshot.js +250 -0
  62. package/dist/git-snapshot.js.map +1 -0
  63. package/dist/git-tracker.d.ts +166 -3
  64. package/dist/git-tracker.d.ts.map +1 -1
  65. package/dist/git-tracker.js +250 -22
  66. package/dist/git-tracker.js.map +1 -1
  67. package/dist/git-utils.d.ts +95 -1
  68. package/dist/git-utils.d.ts.map +1 -1
  69. package/dist/git-utils.js +227 -81
  70. package/dist/git-utils.js.map +1 -1
  71. package/dist/git.d.ts +13 -4
  72. package/dist/git.d.ts.map +1 -1
  73. package/dist/git.js +12 -4
  74. package/dist/git.js.map +1 -1
  75. package/dist/gitignore-checker.d.ts.map +1 -1
  76. package/dist/gitignore-checker.js +15 -6
  77. package/dist/gitignore-checker.js.map +1 -1
  78. package/dist/glob/glob-pattern.js +5 -5
  79. package/dist/glob/glob-pattern.js.map +1 -1
  80. package/dist/index.d.ts +33 -19
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +113 -33
  83. package/dist/index.js.map +1 -1
  84. package/dist/listing-refusal.d.ts +112 -0
  85. package/dist/listing-refusal.d.ts.map +1 -0
  86. package/dist/listing-refusal.js +105 -0
  87. package/dist/listing-refusal.js.map +1 -0
  88. package/dist/numeric-args.d.ts +24 -0
  89. package/dist/numeric-args.d.ts.map +1 -0
  90. package/dist/numeric-args.js +37 -0
  91. package/dist/numeric-args.js.map +1 -0
  92. package/dist/path-containment.d.ts +64 -0
  93. package/dist/path-containment.d.ts.map +1 -0
  94. package/dist/path-containment.js +111 -0
  95. package/dist/path-containment.js.map +1 -0
  96. package/dist/path-core.d.ts +143 -18
  97. package/dist/path-core.d.ts.map +1 -1
  98. package/dist/path-core.js +171 -23
  99. package/dist/path-core.js.map +1 -1
  100. package/dist/path-utils.d.ts.map +1 -1
  101. package/dist/path-utils.js +42 -47
  102. package/dist/path-utils.js.map +1 -1
  103. package/dist/path.d.ts +1 -1
  104. package/dist/path.d.ts.map +1 -1
  105. package/dist/path.js +1 -1
  106. package/dist/path.js.map +1 -1
  107. package/dist/process.d.ts +7 -0
  108. package/dist/process.d.ts.map +1 -1
  109. package/dist/process.js +7 -0
  110. package/dist/process.js.map +1 -1
  111. package/dist/project-utils.d.ts +7 -1
  112. package/dist/project-utils.d.ts.map +1 -1
  113. package/dist/project-utils.js +24 -10
  114. package/dist/project-utils.js.map +1 -1
  115. package/dist/project.d.ts +7 -5
  116. package/dist/project.d.ts.map +1 -1
  117. package/dist/project.js +7 -5
  118. package/dist/project.js.map +1 -1
  119. package/dist/safe-exec.d.ts +40 -11
  120. package/dist/safe-exec.d.ts.map +1 -1
  121. package/dist/safe-exec.js +111 -25
  122. package/dist/safe-exec.js.map +1 -1
  123. package/dist/skill-targets.d.ts +2 -2
  124. package/dist/skill-targets.js +2 -2
  125. package/dist/skill-test/auth-resolver.d.ts +10 -2
  126. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  127. package/dist/skill-test/auth-resolver.js +26 -11
  128. package/dist/skill-test/auth-resolver.js.map +1 -1
  129. package/dist/skill-test/index.d.ts +1 -1
  130. package/dist/skill-test/index.d.ts.map +1 -1
  131. package/dist/skill-test/index.js +1 -1
  132. package/dist/skill-test/index.js.map +1 -1
  133. package/dist/skill-test/spawn-claude.d.ts +30 -1
  134. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  135. package/dist/skill-test/spawn-claude.js +71 -5
  136. package/dist/skill-test/spawn-claude.js.map +1 -1
  137. package/dist/skill-test/transcript.d.ts +41 -2
  138. package/dist/skill-test/transcript.d.ts.map +1 -1
  139. package/dist/skill-test/transcript.js +55 -10
  140. package/dist/skill-test/transcript.js.map +1 -1
  141. package/dist/spawn-hardened.js +0 -1
  142. package/dist/spawn-hardened.js.map +1 -1
  143. package/dist/stdio-blocking.js +13 -14
  144. package/dist/stdio-blocking.js.map +1 -1
  145. package/dist/test-helpers.d.ts +130 -79
  146. package/dist/test-helpers.d.ts.map +1 -1
  147. package/dist/test-helpers.js +229 -124
  148. package/dist/test-helpers.js.map +1 -1
  149. package/dist/testing/executables.d.ts +32 -0
  150. package/dist/testing/executables.d.ts.map +1 -0
  151. package/dist/testing/executables.js +70 -0
  152. package/dist/testing/executables.js.map +1 -0
  153. package/dist/testing/hostile-tree.d.ts +79 -0
  154. package/dist/testing/hostile-tree.d.ts.map +1 -0
  155. package/dist/testing/hostile-tree.js +164 -0
  156. package/dist/testing/hostile-tree.js.map +1 -0
  157. package/dist/testing/platform-gates.d.ts +31 -0
  158. package/dist/testing/platform-gates.d.ts.map +1 -0
  159. package/dist/testing/platform-gates.js +31 -0
  160. package/dist/testing/platform-gates.js.map +1 -0
  161. package/dist/testing/temp-dir.d.ts +255 -0
  162. package/dist/testing/temp-dir.d.ts.map +1 -0
  163. package/dist/testing/temp-dir.js +363 -0
  164. package/dist/testing/temp-dir.js.map +1 -0
  165. package/dist/testing.d.ts +94 -3
  166. package/dist/testing.d.ts.map +1 -1
  167. package/dist/testing.js +119 -3
  168. package/dist/testing.js.map +1 -1
  169. package/dist/text-content.d.ts +197 -0
  170. package/dist/text-content.d.ts.map +1 -0
  171. package/dist/text-content.js +354 -0
  172. package/dist/text-content.js.map +1 -0
  173. package/dist/text-file.d.ts +45 -0
  174. package/dist/text-file.d.ts.map +1 -0
  175. package/dist/text-file.js +51 -0
  176. package/dist/text-file.js.map +1 -0
  177. package/dist/text.d.ts +16 -0
  178. package/dist/text.d.ts.map +1 -0
  179. package/dist/text.js +16 -0
  180. package/dist/text.js.map +1 -0
  181. package/dist/timing-dump.d.ts +136 -0
  182. package/dist/timing-dump.d.ts.map +1 -0
  183. package/dist/timing-dump.js +229 -0
  184. package/dist/timing-dump.js.map +1 -0
  185. package/dist/windows-shell.d.ts +0 -1
  186. package/dist/windows-shell.d.ts.map +1 -1
  187. package/dist/windows-shell.js +0 -1
  188. package/dist/windows-shell.js.map +1 -1
  189. package/dist/zod-introspection.js +2 -2
  190. package/dist/zod-introspection.js.map +1 -1
  191. package/eslint/README.md +224 -50
  192. package/eslint/index.cjs +111 -97
  193. package/eslint/index.d.cts +16 -6
  194. package/eslint/rules/callee-name.cjs +23 -0
  195. package/eslint/rules/commands-import-boundary.cjs +148 -0
  196. package/eslint/rules/dead-import.cjs +62 -12
  197. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  198. package/eslint/rules/eslint-rule-factory.cjs +21 -3
  199. package/eslint/rules/exempt-path-matcher.cjs +2 -1
  200. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  201. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  202. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  203. package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
  204. package/eslint/rules/no-blind-catch.cjs +171 -0
  205. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  206. package/eslint/rules/no-decaying-referent.cjs +217 -0
  207. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  208. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  209. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  210. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  211. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  212. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  213. package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
  214. package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
  215. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  216. package/eslint/rules/no-manual-path-normalize.cjs +146 -96
  217. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  218. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  219. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  220. package/eslint/rules/no-path-startswith.cjs +7 -2
  221. package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
  222. package/eslint/rules/no-raw-node-path.cjs +563 -0
  223. package/eslint/rules/no-raw-text-decode.cjs +305 -0
  224. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  225. package/eslint/rules/no-self-package-import.cjs +168 -0
  226. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  227. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  228. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  229. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  230. package/eslint/rules/no-version-literal.cjs +151 -0
  231. package/eslint/rules/prefer-startswith-over-regex.cjs +33 -1
  232. package/eslint/rules/require-justified-skip.cjs +8 -2
  233. package/eslint/rules/safe-import.cjs +1 -1
  234. package/package.json +16 -16
  235. package/dist/link-auth/build-headers.d.ts +0 -34
  236. package/dist/link-auth/build-headers.d.ts.map +0 -1
  237. package/dist/link-auth/build-headers.js +0 -58
  238. package/dist/link-auth/build-headers.js.map +0 -1
  239. package/dist/link-auth/expand-macro.d.ts +0 -38
  240. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  241. package/dist/link-auth/expand-macro.js +0 -133
  242. package/dist/link-auth/expand-macro.js.map +0 -1
  243. package/dist/link-auth/macros.yaml +0 -50
  244. package/dist/link-auth/resolve-token.d.ts +0 -83
  245. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  246. package/dist/link-auth/resolve-token.js +0 -101
  247. package/dist/link-auth/resolve-token.js.map +0 -1
  248. package/dist/link-auth/resolve.d.ts +0 -102
  249. package/dist/link-auth/resolve.d.ts.map +0 -1
  250. package/dist/link-auth/resolve.js +0 -66
  251. package/dist/link-auth/resolve.js.map +0 -1
  252. package/dist/link-auth/rewrite.d.ts +0 -52
  253. package/dist/link-auth/rewrite.d.ts.map +0 -1
  254. package/dist/link-auth/rewrite.js +0 -102
  255. package/dist/link-auth/rewrite.js.map +0 -1
  256. package/dist/link-auth/select-provider.d.ts +0 -30
  257. package/dist/link-auth/select-provider.d.ts.map +0 -1
  258. package/dist/link-auth/select-provider.js +0 -55
  259. package/dist/link-auth/select-provider.js.map +0 -1
  260. package/dist/link-auth/template.d.ts +0 -40
  261. package/dist/link-auth/template.d.ts.map +0 -1
  262. package/dist/link-auth/template.js +0 -89
  263. package/dist/link-auth/template.js.map +0 -1
  264. package/dist/link-auth/transforms.d.ts +0 -46
  265. package/dist/link-auth/transforms.d.ts.map +0 -1
  266. package/dist/link-auth/transforms.js +0 -52
  267. package/dist/link-auth/transforms.js.map +0 -1
  268. package/dist/template-entry.d.ts +0 -10
  269. package/dist/template-entry.d.ts.map +0 -1
  270. package/dist/template-entry.js +0 -10
  271. package/dist/template-entry.js.map +0 -1
  272. package/dist/template.d.ts +0 -7
  273. package/dist/template.d.ts.map +0 -1
  274. package/dist/template.js +0 -18
  275. package/dist/template.js.map +0 -1
  276. package/eslint/rules/no-path-join.cjs +0 -14
  277. package/eslint/rules/no-path-relative.cjs +0 -14
  278. package/eslint/rules/no-path-resolve.cjs +0 -14
  279. package/eslint/rules/path-function-rule-factory.cjs +0 -417
@@ -1,102 +1,153 @@
1
+ declare const symlinkCapabilityBrand: unique symbol;
1
2
  /**
2
- * Get isolated test output directory for current test run
3
+ * Proof that this process can create filesystem symlinks.
3
4
  *
4
- * Creates a unique directory under `packages/{packageName}/.test-output/{testType}/{runId}`
5
- * where runId is `{timestamp}-{randomId}` to ensure isolation across parallel test runs.
5
+ * The only way to obtain one is {@link symlinkCapability}, and it exists at
6
+ * all only when a real probe already succeeded — so a function that requires
7
+ * this as a parameter cannot be reached by code that skipped the check. That
8
+ * is the point of branding it rather than passing a `boolean`: forgetting the
9
+ * check becomes a type error instead of a runtime `EPERM` on a machine you
10
+ * don't control.
11
+ */
12
+ export type SymlinkCapability = {
13
+ readonly [symlinkCapabilityBrand]: true;
14
+ };
15
+ /**
16
+ * Whether this PROCESS can create symlinks — probed once and memoized.
6
17
  *
7
- * @param packageName - Name of package (e.g., 'rag-lancedb')
8
- * @param testType - Type of test ('unit', 'integration', 'system')
9
- * @param subdirs - Optional subdirectories to create within the test output directory
10
- * @returns Absolute path to the created directory
18
+ * On Windows, `symlink()` needs either Developer Mode or
19
+ * `SeCreateSymbolicLinkPrivilege`. That privilege lives on the process's
20
+ * security token, not on any one directory: it cannot change between calls
21
+ * within a single run, so probing it once and reusing the result is a
22
+ * memoization, not a shortcut that risks a stale answer. (A filesystem that
23
+ * itself has no symlinks — some network shares, some FAT variants — answers
24
+ * `ENOTSUP` and is read as the same "no"; every fixture in this repo creates
25
+ * its roots under {@link normalizedTmpdir}, so it never arises here.)
11
26
  *
12
- * @example
13
- * ```typescript
14
- * // Create isolated database directory for system tests
15
- * const dbPath = getTestOutputDir('rag-lancedb', 'system', 'databases', 'test-db');
16
- * // Result: packages/rag-lancedb/.test-output/system/20260105-143022-abc123/databases/test-db
27
+ * Because the answer is memoized for the whole process, what reads as "no"
28
+ * matters more than usual: a `null` here silently `skip()`s every symlink test
29
+ * for the rest of the run. So ONLY {@link SYMLINK_UNSUPPORTED_ERRNOS} is a
30
+ * no. A tmpdir that is unwritable or missing, or a bug, is not an answer about
31
+ * symlinks at all and stays loud rather than becoming a process-wide skip for
32
+ * a reason nothing reported.
17
33
  *
18
- * // Create temporary file directory for integration tests
19
- * const tempDir = getTestOutputDir('agent-skills', 'integration', 'temp-files');
20
- * // Result: packages/agent-skills/.test-output/integration/20260105-143022-def456/temp-files
21
- * ```
34
+ * Fixtures that depend on symlinks must ask rather than assume — and, having
35
+ * asked, must SAY they skipped. A symlink case that silently no-ops reads as
36
+ * a passing test for a property nobody exercised.
37
+ *
38
+ * @returns A {@link SymlinkCapability} token when this process can create
39
+ * symlinks, else `null`. Route the `null` case through vitest's `skip()`
40
+ * rather than a plain `return`, so the skip is visible in the report.
22
41
  */
23
- export declare function getTestOutputDir(packageName: string, testType: 'unit' | 'integration' | 'system', ...subdirs: string[]): string;
42
+ export declare function symlinkCapability(): SymlinkCapability | null;
24
43
  /**
25
- * Get the base test output directory for a package
26
- * Useful for cleanup operations that need to remove all test output
44
+ * Create a symlink — the one sanctioned call site for `fs.symlinkSync` in
45
+ * test code. Requires a {@link SymlinkCapability}, which only
46
+ * {@link symlinkCapability} can mint, so a test cannot reach the real
47
+ * syscall without first proving (or explicitly bypassing via `skip()`) that
48
+ * this host supports it.
27
49
  *
28
- * @param packageName - Name of package (e.g., 'rag-lancedb')
29
- * @returns Absolute path to packages/{packageName}/.test-output
50
+ * @param _cap - Proof from {@link symlinkCapability} that this host can create symlinks
51
+ * @param target - The existing path the new link should point at
52
+ * @param path - Where to create the link
53
+ * @param type - Windows-only link-type hint (`'file'` \| `'dir'` \| `'junction'`); ignored on POSIX
54
+ */
55
+ export declare function createSymlink(_cap: SymlinkCapability, target: string, path: string, type?: 'dir' | 'file' | 'junction'): void;
56
+ /**
57
+ * The async counterpart of {@link createSymlink}, for fixtures already using
58
+ * `node:fs/promises`. Same capability requirement, same reasoning.
30
59
  *
31
- * @example
32
- * ```typescript
33
- * const baseDir = getTestOutputBase('rag-lancedb');
34
- * // Result: packages/rag-lancedb/.test-output
35
- * ```
60
+ * @param _cap - Proof from {@link symlinkCapability} that this host can create symlinks
61
+ * @param target - The existing path the new link should point at
62
+ * @param path - Where to create the link
63
+ * @param type - Windows-only link-type hint (`'file'` \| `'dir'` \| `'junction'`); ignored on POSIX
64
+ */
65
+ export declare function createSymlinkAsync(_cap: SymlinkCapability, target: string, path: string, type?: 'dir' | 'file' | 'junction'): Promise<void>;
66
+ /**
67
+ * The variables git exports into a hook, which a fixture must clear before it
68
+ * can fabricate its own.
69
+ *
70
+ * These are the ones git sets *for* you. Deliberately **not** the operator's own
71
+ * `GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`/`GLOBAL`/`SYSTEM` channel — a test may be
72
+ * using that on purpose to point a clone at a local path, and clearing it sends
73
+ * the clone to the network instead.
36
74
  */
37
- export declare function getTestOutputBase(packageName: string): string;
75
+ export declare const INHERITED_GIT_ENV: readonly ["GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CEILING_DIRECTORIES", "GIT_COMMON_DIR", "GIT_CONFIG_PARAMETERS", "GIT_DIR", "GIT_DISCOVERY_ACROSS_FILESYSTEM", "GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_INDEX_VERSION", "GIT_NAMESPACE", "GIT_NOTES_REF", "GIT_OBJECT_DIRECTORY", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_WORK_TREE"];
38
76
  /**
39
- * Per-suite temp directory pattern (async version)
40
- * Creates a single temp directory for the entire test suite,
41
- * with subdirectories for each test. This is 3-5x faster on Windows
42
- * than creating a new mkdtemp for each test.
77
+ * Remove every inherited git redirection from `process.env`, and hand back the
78
+ * undo.
43
79
  *
44
- * @param prefix - Prefix for the suite temp directory name
45
- * @returns Suite helper with beforeAll, afterAll, beforeEach, afterEach, and getTempDir
80
+ * A test that fabricates a hook environment has to start from a known-clean one,
81
+ * or it inherits whatever the *outer* runner exported and can no longer tell its
82
+ * own fixture apart from the ambient state — it then passes or fails for reasons
83
+ * it never set up. Restoring afterwards matters just as much: these are
84
+ * process-global, so a test that leaks `GIT_DIR` silently redirects every later
85
+ * test sharing the worker.
46
86
  *
47
- * @example
48
- * ```typescript
49
- * const suite = setupAsyncTempDirSuite('my-test');
87
+ * ⚠️ **The key list is restated here on purpose, not by oversight.** Deriving it
88
+ * from `@vibe-validate/git`'s `stripGitEnv()` would be tidier, and it is exactly
89
+ * what this function did for one revision — but this module is the `./testing`
90
+ * subpath, which `subpath-purity.test.ts` pins as reaching **no third-party
91
+ * package at all** so it stays importable with zero dependencies installed. One
92
+ * import cost that property. The drift risk the derivation was avoiding is
93
+ * handled instead by {@link "../test/test-helpers-git-env.test".default}, which
94
+ * asserts this list equals what the shipped scrub removes.
50
95
  *
51
- * describe('my tests', () => {
52
- * beforeAll(suite.beforeAll);
53
- * afterAll(suite.afterAll);
54
- * beforeEach(suite.beforeEach);
96
+ * @returns A function restoring every variable to its prior value, putting back
97
+ * "was not set" as unset rather than as an empty string
55
98
  *
56
- * it('test 1', async () => {
57
- * const tempDir = suite.getTempDir();
58
- * // Use tempDir...
59
- * });
60
- * });
99
+ * @example
100
+ * ```typescript
101
+ * let restoreGitEnv: () => void;
102
+ * beforeEach(() => { restoreGitEnv = detachGitEnv(); });
103
+ * afterEach(() => { restoreGitEnv(); });
61
104
  * ```
62
105
  */
63
- export declare function setupAsyncTempDirSuite(prefix: string): {
64
- beforeAll: () => Promise<void>;
65
- afterAll: () => Promise<void>;
66
- beforeEach: () => Promise<void>;
67
- afterEach: () => Promise<void>;
68
- getTempDir: () => string;
69
- };
106
+ export declare function detachGitEnv(): () => void;
70
107
  /**
71
- * Per-suite temp directory pattern (sync version)
72
- * Creates a single temp directory for the entire test suite,
73
- * with subdirectories for each test. This is 3-5x faster on Windows
74
- * than creating a new mkdtemp for each test.
75
- *
76
- * @param prefix - Prefix for the suite temp directory name
77
- * @returns Suite helper with beforeAll, afterAll, beforeEach, afterEach, and getTempDir
108
+ * The errno-shaped error a refused `fs` call throws: a message, the `code`,
109
+ * and the `syscall`, exactly as Node shapes one.
110
+ */
111
+ export declare function errnoError(code: string, syscall: string, target: string): NodeJS.ErrnoException;
112
+ /** The sync `node:fs` calls a refusal can be injected into. */
113
+ export type RefusableSyncFsMethod = 'readdirSync' | 'readFileSync' | 'statSync' | 'lstatSync' | 'realpathSync' | 'renameSync' | 'rmSync' | 'unlinkSync';
114
+ /** The `node:fs/promises` calls a refusal can be injected into. */
115
+ export type RefusableAsyncFsMethod = 'readdir' | 'readFile' | 'stat' | 'lstat' | 'access';
116
+ /**
117
+ * Make `fs[method]` throw `code` for exactly `targetPath` until the returned
118
+ * restore is called; every other path, and every other method, stays real.
78
119
  *
79
- * @example
80
- * ```typescript
81
- * const suite = setupSyncTempDirSuite('my-test');
120
+ * A patch rather than a `chmod`: `chmod` reaches one errno (`EACCES`), only
121
+ * where POSIX modes bind, and not as root — and the property under test is
122
+ * "any refusal that is not an absence", so `EACCES`, `ELOOP`, `EMFILE` must all
123
+ * be reachable. What a walk under test meets is ONE refused call inside an
124
+ * otherwise ordinary tree; a walk that gave up entirely would pass a test where
125
+ * everything was refused.
82
126
  *
83
- * describe('my tests', () => {
84
- * beforeAll(suite.beforeAll);
85
- * afterAll(suite.afterAll);
86
- * beforeEach(suite.beforeEach);
127
+ * Lives in the shipped helpers because consumers in five packages each need to
128
+ * refuse a call, and the duplication gate refuses five copies.
129
+ */
130
+ export declare function refuseSyncFs(method: RefusableSyncFsMethod, targetPath: string, code: string): () => void;
131
+ /**
132
+ * `fs/promises[method]` rejects with `code` for exactly `targetPath` until the
133
+ * returned restore is called; every other path, and every other method, is real.
134
+ */
135
+ export declare function refuseAsyncFs(method: RefusableAsyncFsMethod, targetPath: string, code: string): () => void;
136
+ /**
137
+ * Run `body` while `fs[method]` throws `code` for exactly `targetPath`; the
138
+ * patch is lifted however `body` exits. See {@link refuseSyncFs}.
139
+ */
140
+ export declare function withSyncFsRefused<T>(method: RefusableSyncFsMethod, targetPath: string, code: string, body: () => T | Promise<T>): Promise<T>;
141
+ /**
142
+ * Run `body` with `fs.readdirSync` of exactly `directory` throwing an error
143
+ * carrying errno `code`. The listing case of {@link withSyncFsRefused}, named
144
+ * because refusing a LISTING is the question the crawler's consumers ask.
87
145
  *
88
- * it('test 1', () => {
89
- * const tempDir = suite.getTempDir();
90
- * // Use tempDir...
91
- * });
92
- * });
93
- * ```
146
+ * @param directory - Absolute path of the one directory to refuse
147
+ * @param code - The errno to reject with
148
+ * @param body - Runs while the refusal is in force; may be async
149
+ * @returns Whatever `body` returned
94
150
  */
95
- export declare function setupSyncTempDirSuite(prefix: string): {
96
- beforeAll: () => void;
97
- afterAll: () => void;
98
- beforeEach: () => void;
99
- afterEach: () => void;
100
- getTempDir: () => string;
101
- };
151
+ export declare function withReaddirSyncRefused<T>(directory: string, code: string, body: () => T | Promise<T>): Promise<T>;
152
+ export {};
102
153
  //# sourceMappingURL=test-helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,QAAQ,EAC3C,GAAG,OAAO,EAAE,MAAM,EAAE,GACnB,MAAM,CAuBR;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG;IACtD,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,MAAM,CAAC;CAC1B,CAyBA;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG;IACrD,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,UAAU,EAAE,MAAM,MAAM,CAAC;CAC1B,CAwBA"}
1
+ {"version":3,"file":"test-helpers.d.ts","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"AAQA,OAAO,CAAC,MAAM,sBAAsB,EAAE,OAAO,MAAM,CAAC;AAEpD;;;;;;;;;GASG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAAE,QAAQ,CAAC,CAAC,sBAAsB,CAAC,EAAE,IAAI,CAAA;CAAE,CAAC;AAe5E;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,iBAAiB,IAAI,iBAAiB,GAAG,IAAI,CAwB5D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,UAAU,GACjC,IAAI,CAEN;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,UAAU,GACjC,OAAO,CAAC,IAAI,CAAC,CAEf;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,yUAgBpB,CAAC;AAEX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,YAAY,IAAI,MAAM,IAAI,CAoBzC;AAGD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,cAAc,CAE/F;AAED,+DAA+D;AAC/D,MAAM,MAAM,qBAAqB,GAC7B,aAAa,GACb,cAAc,GACd,UAAU,GACV,WAAW,GACX,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,YAAY,CAAC;AAEjB,mEAAmE;AACnE,MAAM,MAAM,sBAAsB,GAAG,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAiB1F;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,IAAI,CAQxG;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,IAAI,CAQ1G;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,MAAM,EAAE,qBAAqB,EAC7B,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACzB,OAAO,CAAC,CAAC,CAAC,CAOZ;AAED;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAC5C,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACzB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
@@ -1,155 +1,260 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdtempSync, rmSync } from 'node:fs';
2
+ import nodeFs, { rmSync, symlinkSync } from 'node:fs';
3
3
  import fs from 'node:fs/promises';
4
- import { mkdirSyncReal, normalizedTmpdir, safePath } from './path-utils.js';
4
+ import { syncBuiltinESMExports } from 'node:module';
5
+ import { isFilesystemAccessError } from './errors/errno.js';
6
+ import { normalizedTmpdir, safePath, toForwardSlash } from './path-utils.js';
7
+ let cachedCapability;
5
8
  /**
6
- * Get isolated test output directory for current test run
9
+ * The errnos that mean "this host cannot create symlinks": Windows without
10
+ * Developer Mode or `SeCreateSymbolicLinkPrivilege` (`EPERM`), and a
11
+ * filesystem that has no symlinks to offer (`ENOTSUP` / `EOPNOTSUPP`).
12
+ */
13
+ const SYMLINK_UNSUPPORTED_ERRNOS = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP']);
14
+ function isSymlinkUnsupported(error) {
15
+ return error instanceof Error && 'code' in error && typeof error.code === 'string' && SYMLINK_UNSUPPORTED_ERRNOS.has(error.code);
16
+ }
17
+ /**
18
+ * Whether this PROCESS can create symlinks — probed once and memoized.
7
19
  *
8
- * Creates a unique directory under `packages/{packageName}/.test-output/{testType}/{runId}`
9
- * where runId is `{timestamp}-{randomId}` to ensure isolation across parallel test runs.
20
+ * On Windows, `symlink()` needs either Developer Mode or
21
+ * `SeCreateSymbolicLinkPrivilege`. That privilege lives on the process's
22
+ * security token, not on any one directory: it cannot change between calls
23
+ * within a single run, so probing it once and reusing the result is a
24
+ * memoization, not a shortcut that risks a stale answer. (A filesystem that
25
+ * itself has no symlinks — some network shares, some FAT variants — answers
26
+ * `ENOTSUP` and is read as the same "no"; every fixture in this repo creates
27
+ * its roots under {@link normalizedTmpdir}, so it never arises here.)
10
28
  *
11
- * @param packageName - Name of package (e.g., 'rag-lancedb')
12
- * @param testType - Type of test ('unit', 'integration', 'system')
13
- * @param subdirs - Optional subdirectories to create within the test output directory
14
- * @returns Absolute path to the created directory
29
+ * Because the answer is memoized for the whole process, what reads as "no"
30
+ * matters more than usual: a `null` here silently `skip()`s every symlink test
31
+ * for the rest of the run. So ONLY {@link SYMLINK_UNSUPPORTED_ERRNOS} is a
32
+ * no. A tmpdir that is unwritable or missing, or a bug, is not an answer about
33
+ * symlinks at all and stays loud rather than becoming a process-wide skip for
34
+ * a reason nothing reported.
15
35
  *
16
- * @example
17
- * ```typescript
18
- * // Create isolated database directory for system tests
19
- * const dbPath = getTestOutputDir('rag-lancedb', 'system', 'databases', 'test-db');
20
- * // Result: packages/rag-lancedb/.test-output/system/20260105-143022-abc123/databases/test-db
36
+ * Fixtures that depend on symlinks must ask rather than assume — and, having
37
+ * asked, must SAY they skipped. A symlink case that silently no-ops reads as
38
+ * a passing test for a property nobody exercised.
21
39
  *
22
- * // Create temporary file directory for integration tests
23
- * const tempDir = getTestOutputDir('agent-skills', 'integration', 'temp-files');
24
- * // Result: packages/agent-skills/.test-output/integration/20260105-143022-def456/temp-files
25
- * ```
40
+ * @returns A {@link SymlinkCapability} token when this process can create
41
+ * symlinks, else `null`. Route the `null` case through vitest's `skip()`
42
+ * rather than a plain `return`, so the skip is visible in the report.
26
43
  */
27
- export function getTestOutputDir(packageName, testType, ...subdirs) {
28
- // Generate unique run ID: timestamp + random hex
29
- const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').slice(0, 19);
30
- const randomId = randomBytes(4).toString('hex');
31
- const runId = `${timestamp}-${randomId}`;
32
- // Find project root (assuming we're always in packages/*/test/*)
33
- const projectRoot = safePath.resolve(process.cwd());
34
- // Build path: packages/{packageName}/.test-output/{testType}/{runId}/{...subdirs}
35
- const testOutputDir = safePath.join(projectRoot, 'packages', packageName, '.test-output', testType, runId, ...subdirs);
36
- // Create directory structure and return normalized path
37
- return mkdirSyncReal(testOutputDir, { recursive: true });
44
+ export function symlinkCapability() {
45
+ if (cachedCapability === undefined) {
46
+ const probe = safePath.join(normalizedTmpdir(), `.vat-symlink-probe-${randomBytes(4).toString('hex')}`);
47
+ try {
48
+ symlinkSync('.', probe);
49
+ cachedCapability = {};
50
+ }
51
+ catch (error) {
52
+ if (!isSymlinkUnsupported(error))
53
+ throw error;
54
+ cachedCapability = null;
55
+ }
56
+ if (cachedCapability !== null) {
57
+ // Best-effort: the capability answer comes from creation succeeding, not
58
+ // from cleanup — a probe left behind by a failed rmSync (e.g. a transient
59
+ // lock on the freshly-created reparse point) must not flip a real "yes"
60
+ // into a memoized, process-wide "no". Only the filesystem refusing the
61
+ // delete is that case; a bug is not, and stays loud.
62
+ try {
63
+ rmSync(probe, { force: true });
64
+ }
65
+ catch (error) {
66
+ if (!isFilesystemAccessError(error))
67
+ throw error;
68
+ }
69
+ }
70
+ }
71
+ return cachedCapability;
38
72
  }
39
73
  /**
40
- * Get the base test output directory for a package
41
- * Useful for cleanup operations that need to remove all test output
74
+ * Create a symlink — the one sanctioned call site for `fs.symlinkSync` in
75
+ * test code. Requires a {@link SymlinkCapability}, which only
76
+ * {@link symlinkCapability} can mint, so a test cannot reach the real
77
+ * syscall without first proving (or explicitly bypassing via `skip()`) that
78
+ * this host supports it.
42
79
  *
43
- * @param packageName - Name of package (e.g., 'rag-lancedb')
44
- * @returns Absolute path to packages/{packageName}/.test-output
80
+ * @param _cap - Proof from {@link symlinkCapability} that this host can create symlinks
81
+ * @param target - The existing path the new link should point at
82
+ * @param path - Where to create the link
83
+ * @param type - Windows-only link-type hint (`'file'` \| `'dir'` \| `'junction'`); ignored on POSIX
84
+ */
85
+ export function createSymlink(_cap, target, path, type) {
86
+ symlinkSync(target, path, type);
87
+ }
88
+ /**
89
+ * The async counterpart of {@link createSymlink}, for fixtures already using
90
+ * `node:fs/promises`. Same capability requirement, same reasoning.
45
91
  *
46
- * @example
47
- * ```typescript
48
- * const baseDir = getTestOutputBase('rag-lancedb');
49
- * // Result: packages/rag-lancedb/.test-output
50
- * ```
92
+ * @param _cap - Proof from {@link symlinkCapability} that this host can create symlinks
93
+ * @param target - The existing path the new link should point at
94
+ * @param path - Where to create the link
95
+ * @param type - Windows-only link-type hint (`'file'` \| `'dir'` \| `'junction'`); ignored on POSIX
51
96
  */
52
- export function getTestOutputBase(packageName) {
53
- const projectRoot = safePath.resolve(process.cwd());
54
- return safePath.join(projectRoot, 'packages', packageName, '.test-output');
97
+ export async function createSymlinkAsync(_cap, target, path, type) {
98
+ await fs.symlink(target, path, type);
55
99
  }
56
100
  /**
57
- * Per-suite temp directory pattern (async version)
58
- * Creates a single temp directory for the entire test suite,
59
- * with subdirectories for each test. This is 3-5x faster on Windows
60
- * than creating a new mkdtemp for each test.
101
+ * The variables git exports into a hook, which a fixture must clear before it
102
+ * can fabricate its own.
61
103
  *
62
- * @param prefix - Prefix for the suite temp directory name
63
- * @returns Suite helper with beforeAll, afterAll, beforeEach, afterEach, and getTempDir
104
+ * These are the ones git sets *for* you. Deliberately **not** the operator's own
105
+ * `GIT_CONFIG_COUNT`/`KEY_n`/`VALUE_n`/`GLOBAL`/`SYSTEM` channel — a test may be
106
+ * using that on purpose to point a clone at a local path, and clearing it sends
107
+ * the clone to the network instead.
108
+ */
109
+ export const INHERITED_GIT_ENV = [
110
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
111
+ 'GIT_CEILING_DIRECTORIES',
112
+ 'GIT_COMMON_DIR',
113
+ 'GIT_CONFIG_PARAMETERS',
114
+ 'GIT_DIR',
115
+ 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
116
+ 'GIT_GRAFT_FILE',
117
+ 'GIT_INDEX_FILE',
118
+ 'GIT_INDEX_VERSION',
119
+ 'GIT_NAMESPACE',
120
+ 'GIT_NOTES_REF',
121
+ 'GIT_OBJECT_DIRECTORY',
122
+ 'GIT_PREFIX',
123
+ 'GIT_SHALLOW_FILE',
124
+ 'GIT_WORK_TREE',
125
+ ];
126
+ /**
127
+ * Remove every inherited git redirection from `process.env`, and hand back the
128
+ * undo.
64
129
  *
65
- * @example
66
- * ```typescript
67
- * const suite = setupAsyncTempDirSuite('my-test');
130
+ * A test that fabricates a hook environment has to start from a known-clean one,
131
+ * or it inherits whatever the *outer* runner exported and can no longer tell its
132
+ * own fixture apart from the ambient state — it then passes or fails for reasons
133
+ * it never set up. Restoring afterwards matters just as much: these are
134
+ * process-global, so a test that leaks `GIT_DIR` silently redirects every later
135
+ * test sharing the worker.
136
+ *
137
+ * ⚠️ **The key list is restated here on purpose, not by oversight.** Deriving it
138
+ * from `@vibe-validate/git`'s `stripGitEnv()` would be tidier, and it is exactly
139
+ * what this function did for one revision — but this module is the `./testing`
140
+ * subpath, which `subpath-purity.test.ts` pins as reaching **no third-party
141
+ * package at all** so it stays importable with zero dependencies installed. One
142
+ * import cost that property. The drift risk the derivation was avoiding is
143
+ * handled instead by {@link "../test/test-helpers-git-env.test".default}, which
144
+ * asserts this list equals what the shipped scrub removes.
68
145
  *
69
- * describe('my tests', () => {
70
- * beforeAll(suite.beforeAll);
71
- * afterAll(suite.afterAll);
72
- * beforeEach(suite.beforeEach);
146
+ * @returns A function restoring every variable to its prior value, putting back
147
+ * "was not set" as unset rather than as an empty string
73
148
  *
74
- * it('test 1', async () => {
75
- * const tempDir = suite.getTempDir();
76
- * // Use tempDir...
77
- * });
78
- * });
149
+ * @example
150
+ * ```typescript
151
+ * let restoreGitEnv: () => void;
152
+ * beforeEach(() => { restoreGitEnv = detachGitEnv(); });
153
+ * afterEach(() => { restoreGitEnv(); });
79
154
  * ```
80
155
  */
81
- export function setupAsyncTempDirSuite(prefix) {
82
- let suiteDir = '';
83
- let tempDir = '';
84
- let testCounter = 0;
85
- return {
86
- beforeAll: async () => {
87
- suiteDir = await fs.mkdtemp(safePath.join(normalizedTmpdir(), `${prefix}-suite-`));
88
- },
89
- afterAll: async () => {
90
- if (suiteDir) {
91
- await fs.rm(suiteDir, { recursive: true, force: true });
92
- }
93
- },
94
- beforeEach: async () => {
95
- testCounter++;
96
- tempDir = safePath.join(suiteDir, `test-${testCounter}`);
97
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- tempDir is from mkdtemp
98
- await fs.mkdir(tempDir, { recursive: true });
99
- },
100
- afterEach: async () => {
101
- // Per-test cleanup handled by suite cleanup
102
- },
103
- getTempDir: () => tempDir,
156
+ export function detachGitEnv() {
157
+ const saved = new Map();
158
+ const forget = (name) => {
159
+ saved.set(name, process.env[name]);
160
+ delete process.env[name];
104
161
  };
162
+ for (const name of INHERITED_GIT_ENV) {
163
+ forget(name);
164
+ }
165
+ return () => {
166
+ for (const [name, value] of saved) {
167
+ // Deleted first so an absent variable is restored as absent: assigning
168
+ // `undefined` would leave the literal string 'undefined' behind.
169
+ delete process.env[name];
170
+ if (value !== undefined)
171
+ process.env[name] = value;
172
+ }
173
+ };
174
+ }
175
+ /**
176
+ * The errno-shaped error a refused `fs` call throws: a message, the `code`,
177
+ * and the `syscall`, exactly as Node shapes one.
178
+ */
179
+ export function errnoError(code, syscall, target) {
180
+ return Object.assign(new Error(`${code}: refused, ${syscall} '${target}'`), { code, syscall });
105
181
  }
106
182
  /**
107
- * Per-suite temp directory pattern (sync version)
108
- * Creates a single temp directory for the entire test suite,
109
- * with subdirectories for each test. This is 3-5x faster on Windows
110
- * than creating a new mkdtemp for each test.
183
+ * Assign `fn` over `module[method]` and republish the builtin's ESM bindings.
111
184
  *
112
- * @param prefix - Prefix for the suite temp directory name
113
- * @returns Suite helper with beforeAll, afterAll, beforeEach, afterEach, and getTempDir
185
+ * Assigning on the CJS object alone reaches ONLY a `import fs from 'node:fs'`
186
+ * caller — a named or namespace import reads the builtin's ESM bindings, which
187
+ * Node snapshots at import time. `syncBuiltinESMExports()` after each
188
+ * assignment republishes the patch (and the restore) to every import style;
189
+ * measured under vitest: without it, a spy on a named-import caller attached
190
+ * and counted zero, which reads exactly like "this function performs no I/O".
191
+ */
192
+ function republish(module, method, fn) {
193
+ module[method] = fn;
194
+ syncBuiltinESMExports();
195
+ }
196
+ /**
197
+ * Make `fs[method]` throw `code` for exactly `targetPath` until the returned
198
+ * restore is called; every other path, and every other method, stays real.
114
199
  *
115
- * @example
116
- * ```typescript
117
- * const suite = setupSyncTempDirSuite('my-test');
200
+ * A patch rather than a `chmod`: `chmod` reaches one errno (`EACCES`), only
201
+ * where POSIX modes bind, and not as root — and the property under test is
202
+ * "any refusal that is not an absence", so `EACCES`, `ELOOP`, `EMFILE` must all
203
+ * be reachable. What a walk under test meets is ONE refused call inside an
204
+ * otherwise ordinary tree; a walk that gave up entirely would pass a test where
205
+ * everything was refused.
118
206
  *
119
- * describe('my tests', () => {
120
- * beforeAll(suite.beforeAll);
121
- * afterAll(suite.afterAll);
122
- * beforeEach(suite.beforeEach);
207
+ * Lives in the shipped helpers because consumers in five packages each need to
208
+ * refuse a call, and the duplication gate refuses five copies.
209
+ */
210
+ export function refuseSyncFs(method, targetPath, code) {
211
+ const original = nodeFs[method];
212
+ const refused = toForwardSlash(targetPath);
213
+ republish(nodeFs, method, (target, ...rest) => {
214
+ if (toForwardSlash(String(target)) === refused)
215
+ throw errnoError(code, method, String(target));
216
+ return original(target, ...rest);
217
+ });
218
+ return () => republish(nodeFs, method, original);
219
+ }
220
+ /**
221
+ * `fs/promises[method]` rejects with `code` for exactly `targetPath` until the
222
+ * returned restore is called; every other path, and every other method, is real.
223
+ */
224
+ export function refuseAsyncFs(method, targetPath, code) {
225
+ const original = fs[method].bind(fs);
226
+ const refused = toForwardSlash(targetPath);
227
+ republish(fs, method, async (target, ...rest) => {
228
+ if (toForwardSlash(String(target)) === refused)
229
+ throw errnoError(code, method, String(target));
230
+ return original(target, ...rest);
231
+ });
232
+ return () => republish(fs, method, original);
233
+ }
234
+ /**
235
+ * Run `body` while `fs[method]` throws `code` for exactly `targetPath`; the
236
+ * patch is lifted however `body` exits. See {@link refuseSyncFs}.
237
+ */
238
+ export async function withSyncFsRefused(method, targetPath, code, body) {
239
+ const restore = refuseSyncFs(method, targetPath, code);
240
+ try {
241
+ return await body();
242
+ }
243
+ finally {
244
+ restore();
245
+ }
246
+ }
247
+ /**
248
+ * Run `body` with `fs.readdirSync` of exactly `directory` throwing an error
249
+ * carrying errno `code`. The listing case of {@link withSyncFsRefused}, named
250
+ * because refusing a LISTING is the question the crawler's consumers ask.
123
251
  *
124
- * it('test 1', () => {
125
- * const tempDir = suite.getTempDir();
126
- * // Use tempDir...
127
- * });
128
- * });
129
- * ```
252
+ * @param directory - Absolute path of the one directory to refuse
253
+ * @param code - The errno to reject with
254
+ * @param body - Runs while the refusal is in force; may be async
255
+ * @returns Whatever `body` returned
130
256
  */
131
- export function setupSyncTempDirSuite(prefix) {
132
- let suiteDir = '';
133
- let tempDir = '';
134
- let testCounter = 0;
135
- return {
136
- beforeAll: () => {
137
- suiteDir = mkdtempSync(safePath.join(normalizedTmpdir(), `${prefix}-suite-`));
138
- },
139
- afterAll: () => {
140
- if (suiteDir) {
141
- rmSync(suiteDir, { recursive: true, force: true });
142
- }
143
- },
144
- beforeEach: () => {
145
- testCounter++;
146
- tempDir = safePath.join(suiteDir, `test-${testCounter}`);
147
- mkdirSyncReal(tempDir);
148
- },
149
- afterEach: () => {
150
- // Per-test cleanup handled by suite cleanup
151
- },
152
- getTempDir: () => tempDir,
153
- };
257
+ export async function withReaddirSyncRefused(directory, code, body) {
258
+ return withSyncFsRefused('readdirSync', directory, code, body);
154
259
  }
155
260
  //# sourceMappingURL=test-helpers.js.map