@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
package/README.md CHANGED
@@ -24,12 +24,12 @@ The last two columns are the ones that matter when choosing. **"Resolves with ze
24
24
 
25
25
  | Subpath | Contents | Node builtins reached | Third-party | Resolves with zero deps installed? |
26
26
  |---|---|---|---|---|
27
- | `./path` | `safePath`, `toForwardSlash`, `toNfc`, `isAbsolutePath`, `isAbsoluteAnyPlatform`, `hasParentTraversalSegment`, `toAbsolutePath`, `getRelativePath`, `issueLocation` | `path` only | — | **yes** |
27
+ | `./path` | `safePath`, `toForwardSlash`, `toNfc`, `isAbsolutePath`, `isAbsoluteAnyPlatform`, `hasParentTraversalSegment`, `relativeEscapesRoot`, `isSingleFsSegment`, `toAbsolutePath`, `getRelativePath`, `issueLocation` | `path` only | — | **yes** |
28
28
  | `./text` | `decodeTextContent` — the one bytes-to-text seam: BOM-announced UTF-8/UTF-16LE/UTF-16BE/UTF-32LE/UTF-32BE, BOM stripped, UTF-8 assumed otherwise; reports the encoding, whether it was a BOM fact or an assumption, and how many U+FFFD the decode substituted | **none** | — | **yes** |
29
29
  | `./zod` | `ZodTypeNames`, `getZodTypeName`, `isZodType`, `unwrapZodType`, `isZodOptional`, `isZodNullable` | **none** | — | **yes** |
30
30
  | `./glob` | `isGlob`, static base extraction, magic remainder | `path` only | — | **yes** |
31
- | `./fs` | `normalizePath`, `normalizedTmpdir`, `mkdirSyncReal`, `resolveFromImportMeta`, `dynamicImportPath`, `copyDirectory`, `fillSiblingNames`, `classifyFilenameCaseFrom`, `FsLookupCache`, `readTextContent`, `readTextContentSync` | `fs`, `fs/promises`, `os`, `path`, `url`, `util` | — | **yes** |
32
- | `./testing` | `getTestOutputDir`, `getTestOutputBase`, `setupAsyncTempDirSuite`, `setupSyncTempDirSuite`, `removeScratchDir`, `symlinkCapability`, `createSymlink`, `createSymlinkAsync` | `crypto`, `fs`, `fs/promises`, `os`, `path`, `url` | — | **yes** |
31
+ | `./fs` | `normalizePath`, `normalizedTmpdir`, `mkdirSyncReal`, `resolveFromImportMeta`, `dynamicImportPath`, `copyDirectory`, `fillPathSpellings`, `pathSpellingFrom`, `DirectorySpellingIndex`, `spellingWalkRoot`, `fillRealpaths`, `realpathFrom`, `FsLookupCache`, `readTextContent`, `readTextContentSync` | `fs`, `fs/promises`, `os`, `path`, `url`, `util` | — | **yes** |
32
+ | `./testing` | `getTestOutputDir`, `getTestOutputBase`, `setupAsyncTempDirSuite`, `setupSyncTempDirSuite`, `removeScratchDir`, `createTempDir`, `removeTempDir`, `tempDirTracker`, `CANNOT_DENY_READS`, `buildHostileTree`, `HOSTILE_NAMES`, `symlinkCapability`, `createSymlink`, `createSymlinkAsync` | `crypto`, `fs`, `fs/promises`, `os`, `path`, `url` | — | **yes** |
33
33
  | `./asset` | `resolveAssetReference` — paths and npm bare specifiers | `fs`, `module`, `os`, `path`, `url` | — | **yes** |
34
34
  | `./yaml` | `updateYamlIn`, `verifyConfinedYamlEdit` — byte-surgical YAML edits | **none** | `yaml` | no — needs `yaml` |
35
35
  | `./process` | `safeExecSync`, `safeExecResult`, `safeExecFromString`, `isToolAvailable`, `getToolVersion`, `hasShellSyntax`, `CommandExecutionError`, `spawnHardened`, `shouldUseShell`, `windowsShellQuote`, `buildWindowsShellLine`, `resolveShellCommandToken`, `isPathLike`, `makeStdioBlocking`, `describeStdioBlocking` | `child_process`, `path` | `@vibe-validate/git`, `which` | no — needs both |
@@ -134,6 +134,8 @@ These always return forward slashes on every platform, so they are safe for comp
134
134
  - `getRelativePath()` - relative path between two absolute paths
135
135
  - `isAbsolutePath()` / `isAbsoluteAnyPlatform()` - absolute-path predicates
136
136
  - `hasParentTraversalSegment()` - detect `..` segments before using a caller-supplied path
137
+ - `relativeEscapesRoot()` - classify a root-relative path (`safePath.relative(root, p)`) as escaping the root: `..`, `../x`, or an absolute answer (Windows cross-drive). Lexical, for identities and reports; a sink asks `isUnderRoot()` instead
138
+ - `isSingleFsSegment()` - true when a caller-supplied NAME can only ever be one entry directly under whatever it is joined to (no separators, not `.`/`..`, no NUL, no drive spelling). `..cache` passes; `../victim` does not
137
139
  - `issueLocation()` - format a `file:line`-style location relative to a project root
138
140
 
139
141
  ⚠️ **`toNfc()` produces a comparison key, never a path to open.** The same visible filename has two
@@ -158,12 +160,24 @@ These return **OS-native** separators, because they resolve real filesystem iden
158
160
  - `mkdirSyncReal()` - create a directory and return its real path
159
161
  - `resolveFromImportMeta()` - resolve paths relative to an `import.meta.url`
160
162
  - `dynamicImportPath()` - `import()` an absolute path (works on Windows, which rejects bare absolute paths)
161
- - `copyDirectory()`
162
- - `fillSiblingNames(filePaths, fsCache)` - pass 1 of the case-exact existence check: list the parent
163
- directory of every path, de-duplicated by directory and issued concurrently. The only I/O in the pair
164
- - `classifyFilenameCaseFrom(table, filePath)` - pass 2: pure judgement against the table pass 1 returned,
165
- reporting the entry actually on disk when only the case differs. Fill **once** over the whole set and
166
- then judge each path — a fill per path reinstates the serialized `readdir` this shape removes
163
+ - `copyDirectory()` - recursive copy that follows a symlinked directory into the tree it points at — contained: a link whose target is outside the source throws `CopyLinkEscapesSourceError`, and a link back into the tree throws `DirectoryWalkRevisitedError`
164
+ - `isUnderRoot(root, candidate)` - the containment verdict a delete/copy/uninstall sink asks, answered by the filesystem: `'inside'` (a strict descendant, realpath-judged), `'outside'` (including the root itself and any symlink that leaves it), or `'absent'` (nothing there yet, and creating it would land inside). Both sides are canonicalized from their deepest existing ancestor, so a root reached through a symlink still contains its members; a refused path throws rather than reading as absent
165
+ - `direntKind(entry)` - what a `Dirent` itself is: `'file' | 'directory' | 'symlink' | 'other'` — the link question asked first, for walks that must not follow (a delete, a size count)
166
+ - `direntKindFollowing(dir, entry)` / `direntKindFollowingSync()` - what a `Dirent` resolves to, one `stat` for a link: `'file' | 'directory' | 'dangling' | 'other'` — for walks over a trusted tree where a link is how the entry got there. Both exist because `isFile()` and `isDirectory()` are BOTH false for a symlink, so a walk that tests only those drops every link silently (`local/dirent-type-needs-symlink-check` refuses that shape)
167
+ - `FollowedWalk` - the cycle guard every following walk holds: `enter(dir)` on the root and each directory recursed into; a directory reached again under a second spelling (a link back into the tree) throws `DirectoryWalkRevisitedError` instead of recursing until the path length runs out
168
+ - `fillPathSpellings(requests, fsCache)` - pass 1 of the case-exact existence check: walk each
169
+ `{ referrer, target }` from the deepest directory the two share, listing every directory on the way
170
+ down exactly once. The only I/O in the pair
171
+ - `pathSpellingFrom(table, referrer, target)` - pass 2: pure judgement against the table pass 1
172
+ returned, reporting the path as disk spells it when the asked-for spelling differs only by case or
173
+ by Unicode normalization. Fill **once** over the whole set and then judge each reference — a fill
174
+ per reference reinstates the serialized `readdir` this shape removes.
175
+ ⚠️ It judges **every component**, not just the basename: judging only the last one hands each
176
+ directory component back to the host filesystem's own folding, so a link that resolves on
177
+ macOS/APFS 404s on a case-sensitive filesystem — and a basename-only correction still leaves the
178
+ author with a broken path
179
+ - `DirectorySpellingIndex` / `spellingWalkRoot(referrer, target)` - the index the pair walks over, and
180
+ the root it walks from, for a caller that cannot enumerate its targets up front
167
181
  - `FsLookupCache` - per-run memo for `realpath`/`readdir`, sharing in-flight promises. Construct one per
168
182
  validation run and let it die with the run — never a module-level singleton, or a long-lived process
169
183
  answers from a stale directory listing.
@@ -191,6 +205,10 @@ These return **OS-native** separators, because they resolve real filesystem iden
191
205
  ### Test helpers — `@vibe-agent-toolkit/utils/testing`
192
206
 
193
207
  - `setupAsyncTempDirSuite()` / `setupSyncTempDirSuite()` - per-suite temp directories with cleanup
208
+ - `createTempDir()` / `createTempDirAsync()` / `removeTempDir()` / `tempDirTracker()` - the per-call temp-directory primitives; `removeTempDir` refuses, by name, anything not inside the host tmpdir as the filesystem sees it
209
+ - `CANNOT_DENY_READS` / `PERMISSIONS_ENFORCED` - whether a `chmod 000` denies anything on this host (Windows and uid 0 read everything); route through `skip()` so the skip is visible
210
+ - `NODE_EXECUTABLE` / `gitExecutable()` / `resolveExecutable(name)` / `executableCandidates(name, platform, pathext)` - absolute paths for the binaries a fixture spawns: the running node, and `git` found by walking `PATH` once for the first regular executable file (`PATHEXT` names on Windows) and cached. A fixture spawns these instead of a bare `'git'` or `'node'`, so no writable directory on `PATH` decides what runs.
211
+ - `buildHostileTree(base)` / `hostileTreePerTest(prefix)` / `HOSTILE_NAMES` - the one hostile fixture every sink is tested against: a root with a member, a `..`-named member, a symlink pointing out, one pointing in, a dangling one, an alias link to the root, an unreadable directory, a 200-character name, plus a sibling `victim/secret.txt` outside the root; and the table of names (`../victim`, `..`, `.`, `''`, `/victim`, `C:\victim`, a NUL byte, …) a sink must refuse. Fields the host cannot build are `null`
194
212
  - `getTestOutputDir()` / `getTestOutputBase()` - isolated test output paths
195
213
  - `symlinkCapability()` - probes once (memoized per process) whether this host can create symlinks (Windows needs Developer Mode or `SeCreateSymbolicLinkPrivilege`), returning a `SymlinkCapability` token or `null`
196
214
  - `createSymlink()` / `createSymlinkAsync()` - the sanctioned way to create a symlink in test code; both require a `SymlinkCapability` from `symlinkCapability()`, so a test cannot reach the raw syscall without first proving the host supports it (or explicitly skipping via vitest's `skip()`)
@@ -207,10 +225,26 @@ These are CLI-boundary functions: inner libraries should take a root as a parame
207
225
 
208
226
  They are nonetheless on their own [`./project`](#import-narrowly) entry rather than the barrel alone. The entry was briefly withdrawn on the grounds that the functions fit few repos — which is true, and is what the paragraph above says — but that answered the wrong question. What decides whether an *entry* exists is how heavy the only remaining door is, and barrel-only these four once cost five third-party packages to reach while their own code imports nothing but `node:fs` and `node:path`. Publishing the entry is not a claim that the ladder fits you — only that finding out shouldn't cost a dependency graph. The barrel is dependency-free now, which is that same rule applied everywhere rather than a reason to fold `./project` back in.
209
227
 
228
+ ### Errors — `@vibe-agent-toolkit/utils` (barrel only)
229
+
230
+ - `VatError` - the base of every error VAT throws on purpose: `new VatError(code, message, { cause })`. `code` is a stable `SCREAMING_SNAKE` identity a catch block dispatches on; `name` is taken from the subclass.
231
+ - `isVatError(error, code?)` - the dispatch predicate. Reads a `Symbol.for('vat.error')` brand plus `code`, never the prototype chain, so a `dist` copy of a class still matches a `src` instance. A foreign error carrying a `code` (every `node:fs` errno) answers `false`.
232
+ - `prefixMessageOnce(error, prefix)` - prefix a message in place, once per error object — for a seam a retried item passes through more than once, without re-reading the message to find out.
233
+ - `PathEscapesRootError` (on `./path` too) - what `safePath.joinUnderRoot` throws; recognise it with `isVatError(error, PathEscapesRootError.code)`.
234
+
235
+ Never dispatch on `error.message` — the repo's ESLint config refuses `.message.includes(…)` and friends under `packages/*/src`. Prose is for humans and changes when it is improved; a code changes only when the meaning does.
236
+
210
237
  ### Directory crawling — `@vibe-agent-toolkit/utils/crawl`
211
238
 
212
239
  - `crawlDirectory()` / `crawlDirectorySync()` - gitignore-aware directory walks
213
240
  - `NEVER_CRAWL_GLOBS` / `BUILD_OUTPUT_GLOBS` - the standard exclusion sets
241
+ - `UnreadablePolicy` - the REQUIRED `unreadable` option on every crawl (and on `gitLsFiles` /
242
+ `gitLsOthers` under `./git`): `{ refuse: { root, remedy } }` throws `DirectoryListingRefusedError`
243
+ with an adopter-facing, root-relative sentence when a directory cannot be listed;
244
+ `{ degrade: (refusal) => … }` keeps walking and hands the gap to you to report. There is no
245
+ default — a shorter list nothing can tell from a complete one is the failure this option exists
246
+ to prevent, so the caller states which answer is honest for its lane, and an omitted policy is
247
+ refused up front by name.
214
248
 
215
249
  Glob *pattern inspection* is a separate entry, `./glob`, and stays that way: `./glob` is dependency-free and reaches only `node:path`, whereas crawling reaches the filesystem, `git`, and `picomatch`.
216
250
 
@@ -74,8 +74,9 @@ function formatActionableError(specifier, baseDir, cause) {
74
74
  // but a build step in the target package didn't run (or produced different
75
75
  // output). Name the missing file explicitly and point at the publisher.
76
76
  if (code === 'MODULE_NOT_FOUND' && missingPath && missingPath !== specifier && isAbsolutePath(missingPath)) {
77
- const fileExists = safeExistsSync(missingPath);
78
- if (!fileExists) {
77
+ // `existsSync` answers false for every failure and never throws, so no
78
+ // guard around it: the question here is only whether the file is there.
79
+ if (!existsSync(missingPath)) {
79
80
  return (`Failed to resolve asset reference '${specifier}': ` +
80
81
  `the package's "exports" map points to '${missingPath}', but that file does not exist on disk.\n` +
81
82
  `Hint: the target package was found, but a build step did not produce this file. ` +
@@ -108,15 +109,6 @@ function extractMissingModulePath(cause) {
108
109
  const match = CANNOT_FIND_MODULE_RE.exec(cause.message);
109
110
  return match?.[1];
110
111
  }
111
- function safeExistsSync(filePath) {
112
- try {
113
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a Node-resolved exports target, used only to refine the error message
114
- return existsSync(filePath);
115
- }
116
- catch {
117
- return false;
118
- }
119
- }
120
112
  function isBareSpecifier(value) {
121
113
  return BARE_SPECIFIER_RE.test(value);
122
114
  }
@@ -1 +1 @@
1
- {"version":3,"file":"asset-reference.js","sourceRoot":"","sources":["../src/asset-reference.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"asset-reference.js","sourceRoot":"","sources":["../src/asset-reference.ts"],"names":[],"mappings":"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"}
@@ -115,7 +115,7 @@
115
115
  *
116
116
  * ⚠️ A rollup that sums a stratum's rows without regard to pass double-counts
117
117
  * every nested bracket. That is a real reading hazard, not a hypothetical: it is
118
- * what `packages/lab/src/facets/crawl/dump.ts` did until 2026-08-15, and it
118
+ * what `packages/lab/src/facets/crawl/dump.ts` used to do, and it
119
119
  * inflated the two arms by DIFFERENT factors, because they nest to different
120
120
  * depths. That reader now implements the rule above — `crawlRowRole` there is
121
121
  * the executable copy of it — so anyone adding a bracket to this seam should
@@ -204,8 +204,8 @@
204
204
  * It carries the process's own wall and CPU lifetime, like the parse dump, and
205
205
  * for the same reason: these brackets are wall-timed, so a reader has to be able
206
206
  * to see that the process spent its life waiting. It does **not** invite that
207
- * figure to be summed across processes. `parse-timing.ts`'s review finding of
208
- * 2026-08-14 records that the lab sums `process.wallMs` across dumps, which
207
+ * figure to be summed across processes. `parse-timing.ts`'s review finding
208
+ * records that the lab sums `process.wallMs` across dumps, which
209
209
  * double-counts real time under a multi-process verb because the parent
210
210
  * orchestrator's lifetime contains every child's. The reader for THIS dump
211
211
  * publishes one lifetime per process and never a total — see
@@ -115,7 +115,7 @@
115
115
  *
116
116
  * ⚠️ A rollup that sums a stratum's rows without regard to pass double-counts
117
117
  * every nested bracket. That is a real reading hazard, not a hypothetical: it is
118
- * what `packages/lab/src/facets/crawl/dump.ts` did until 2026-08-15, and it
118
+ * what `packages/lab/src/facets/crawl/dump.ts` used to do, and it
119
119
  * inflated the two arms by DIFFERENT factors, because they nest to different
120
120
  * depths. That reader now implements the rule above — `crawlRowRole` there is
121
121
  * the executable copy of it — so anyone adding a bracket to this seam should
@@ -204,8 +204,8 @@
204
204
  * It carries the process's own wall and CPU lifetime, like the parse dump, and
205
205
  * for the same reason: these brackets are wall-timed, so a reader has to be able
206
206
  * to see that the process spent its life waiting. It does **not** invite that
207
- * figure to be summed across processes. `parse-timing.ts`'s review finding of
208
- * 2026-08-14 records that the lab sums `process.wallMs` across dumps, which
207
+ * figure to be summed across processes. `parse-timing.ts`'s review finding
208
+ * records that the lab sums `process.wallMs` across dumps, which
209
209
  * double-counts real time under a multi-process verb because the parent
210
210
  * orchestrator's lifetime contains every child's. The reader for THIS dump
211
211
  * publishes one lifetime per process and never a total — see
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What a directory entry IS — with the symlink question asked first.
3
+ *
4
+ * A `Dirent` from `readdir(…, { withFileTypes: true })` describes the entry
5
+ * itself, so for a symlink BOTH `isFile()` and `isDirectory()` are false. A
6
+ * walk written `if (e.isDirectory()) recurse(); else if (e.isFile()) read();`
7
+ * therefore neither refuses a link nor follows it: it drops the entry on the
8
+ * floor without a word. The sweep found staged skill trees, size accounting
9
+ * and packaging walks with exactly that shape, every one reporting a tree
10
+ * with a symlink in it as clean. `local/dirent-type-needs-symlink-check`
11
+ * refuses that shape; these two helpers are what a walk says instead, and
12
+ * each makes the caller pick a policy by name.
13
+ *
14
+ * - {@link direntKind} — lstat semantics. A link is `'symlink'`, never what it
15
+ * points at. For a walk that must NOT follow (a delete, a size count of
16
+ * what `rm -rf` will remove, a bundle that travels as a tarball).
17
+ * - {@link direntKindFollowing} — stat semantics. A link is answered by its
18
+ * target: `'file'`, `'directory'`, or `'dangling'`. For a walk over a tree
19
+ * the caller trusts (its own build output, a dev-mode install, a fixture),
20
+ * where a link is simply how the entry got there.
21
+ *
22
+ * Neither helper decides for the caller. A `'symlink'` or `'dangling'` that
23
+ * the caller ignores is still ignored — but it is ignored in a `case` the
24
+ * reader can see, which is the whole difference.
25
+ */
26
+ import type { Dirent } from 'node:fs';
27
+ import { VatError } from './errors/vat-error.js';
28
+ /** What the entry itself is. A link is a link. */
29
+ export type DirentKind = 'file' | 'directory' | 'symlink' | 'other';
30
+ /** What the entry resolves to. A link is answered by its target, or is `dangling`. */
31
+ export type FollowedKind = 'file' | 'directory' | 'dangling' | 'other';
32
+ /**
33
+ * Classify an entry WITHOUT following it.
34
+ *
35
+ * @param entry - A `Dirent` from a `withFileTypes` listing
36
+ * @returns `'symlink'` for any link, else the entry's own type
37
+ */
38
+ export declare function direntKind(entry: Dirent): DirentKind;
39
+ /**
40
+ * Classify an entry by what it resolves to, following a link with one `stat`.
41
+ *
42
+ * A non-link entry costs no syscall. A link whose target is absent is
43
+ * `'dangling'`; a link the OS refuses to resolve (`ELOOP`, `EACCES`) is a
44
+ * refusal, not an absence, and stays loud.
45
+ *
46
+ * @param dir - The directory `entry` was listed from
47
+ * @param entry - A `Dirent` from a `withFileTypes` listing of `dir`
48
+ * @returns The target's kind, or `'dangling'`
49
+ */
50
+ export declare function direntKindFollowingSync(dir: string, entry: Dirent): FollowedKind;
51
+ /** The async counterpart of {@link direntKindFollowingSync}. */
52
+ export declare function direntKindFollowing(dir: string, entry: Dirent): Promise<FollowedKind>;
53
+ /** Thrown when a following walk is led back into a directory it has already entered. */
54
+ export declare class DirectoryWalkRevisitedError extends VatError {
55
+ constructor(dir: string, enteredAs: string);
56
+ }
57
+ /**
58
+ * The directories a FOLLOWING walk has entered, by realpath, so a link that
59
+ * leads back into the walk is refused instead of recursed until
60
+ * `ENAMETOOLONG`.
61
+ *
62
+ * A walk that follows links (`direntKindFollowing`) has no cycle guard by
63
+ * construction: `scripts/loop -> .` makes a copy create `dest/loop/loop/…`,
64
+ * writing every file at every level first, and a hashing walk do the same in
65
+ * memory. Every following walk holds one of these and calls {@link enter}
66
+ * on every directory it recurses into — the root included. A REVISIT is
67
+ * refused, not just a cycle: two links to one directory make the walk's
68
+ * output ambiguous (which spelling is the file's path?), and a refusal that
69
+ * names both spellings is the answer the author can act on.
70
+ */
71
+ export declare class FollowedWalk {
72
+ #private;
73
+ /**
74
+ * Record that the walk is entering `dir`. Synchronous on purpose — one
75
+ * realpath per directory entered is nothing beside the listing itself, and
76
+ * the sync and async walkers then share one guard.
77
+ *
78
+ * @param dir - A directory the walk is about to list, in any spelling
79
+ * @throws {DirectoryWalkRevisitedError} When its realpath was entered before
80
+ */
81
+ enter(dir: string): void;
82
+ }
83
+ //# sourceMappingURL=dirent-kind.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dirent-kind.d.ts","sourceRoot":"","sources":["../src/dirent-kind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAKtC,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAIjD,kDAAkD;AAClD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC;AAEpE,sFAAsF;AACtF,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,WAAW,GAAG,UAAU,GAAG,OAAO,CAAC;AAEvE;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAKpD;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAQhF;AAED,gEAAgE;AAChE,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAQ3F;AAED,wFAAwF;AACxF,qBAAa,2BAA4B,SAAQ,QAAQ;gBAC3C,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;CAM3C;AAED;;;;;;;;;;;;;GAaG;AACH,qBAAa,YAAY;;IAGvB;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAMzB"}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What a directory entry IS — with the symlink question asked first.
3
+ *
4
+ * A `Dirent` from `readdir(…, { withFileTypes: true })` describes the entry
5
+ * itself, so for a symlink BOTH `isFile()` and `isDirectory()` are false. A
6
+ * walk written `if (e.isDirectory()) recurse(); else if (e.isFile()) read();`
7
+ * therefore neither refuses a link nor follows it: it drops the entry on the
8
+ * floor without a word. The sweep found staged skill trees, size accounting
9
+ * and packaging walks with exactly that shape, every one reporting a tree
10
+ * with a symlink in it as clean. `local/dirent-type-needs-symlink-check`
11
+ * refuses that shape; these two helpers are what a walk says instead, and
12
+ * each makes the caller pick a policy by name.
13
+ *
14
+ * - {@link direntKind} — lstat semantics. A link is `'symlink'`, never what it
15
+ * points at. For a walk that must NOT follow (a delete, a size count of
16
+ * what `rm -rf` will remove, a bundle that travels as a tarball).
17
+ * - {@link direntKindFollowing} — stat semantics. A link is answered by its
18
+ * target: `'file'`, `'directory'`, or `'dangling'`. For a walk over a tree
19
+ * the caller trusts (its own build output, a dev-mode install, a fixture),
20
+ * where a link is simply how the entry got there.
21
+ *
22
+ * Neither helper decides for the caller. A `'symlink'` or `'dangling'` that
23
+ * the caller ignores is still ignored — but it is ignored in a `case` the
24
+ * reader can see, which is the whole difference.
25
+ */
26
+ import { statSync } from 'node:fs';
27
+ import fs from 'node:fs/promises';
28
+ import { isPathAbsentError } from './errors/errno.js';
29
+ import { VatError } from './errors/vat-error.js';
30
+ import { toForwardSlash } from './path-core.js';
31
+ import { normalizePath, safePath } from './path-utils.js';
32
+ /**
33
+ * Classify an entry WITHOUT following it.
34
+ *
35
+ * @param entry - A `Dirent` from a `withFileTypes` listing
36
+ * @returns `'symlink'` for any link, else the entry's own type
37
+ */
38
+ export function direntKind(entry) {
39
+ if (entry.isSymbolicLink())
40
+ return 'symlink';
41
+ if (entry.isDirectory())
42
+ return 'directory';
43
+ if (entry.isFile())
44
+ return 'file';
45
+ return 'other';
46
+ }
47
+ /** The kind a `stat` result reports. */
48
+ function kindOfStats(stats) {
49
+ if (stats.isDirectory())
50
+ return 'directory';
51
+ if (stats.isFile())
52
+ return 'file';
53
+ return 'other';
54
+ }
55
+ /**
56
+ * Classify an entry by what it resolves to, following a link with one `stat`.
57
+ *
58
+ * A non-link entry costs no syscall. A link whose target is absent is
59
+ * `'dangling'`; a link the OS refuses to resolve (`ELOOP`, `EACCES`) is a
60
+ * refusal, not an absence, and stays loud.
61
+ *
62
+ * @param dir - The directory `entry` was listed from
63
+ * @param entry - A `Dirent` from a `withFileTypes` listing of `dir`
64
+ * @returns The target's kind, or `'dangling'`
65
+ */
66
+ export function direntKindFollowingSync(dir, entry) {
67
+ if (!entry.isSymbolicLink())
68
+ return kindOfStats(entry);
69
+ try {
70
+ return kindOfStats(statSync(safePath.join(dir, entry.name)));
71
+ }
72
+ catch (error) {
73
+ if (isPathAbsentError(error))
74
+ return 'dangling';
75
+ throw error;
76
+ }
77
+ }
78
+ /** The async counterpart of {@link direntKindFollowingSync}. */
79
+ export async function direntKindFollowing(dir, entry) {
80
+ if (!entry.isSymbolicLink())
81
+ return kindOfStats(entry);
82
+ try {
83
+ return kindOfStats(await fs.stat(safePath.join(dir, entry.name)));
84
+ }
85
+ catch (error) {
86
+ if (isPathAbsentError(error))
87
+ return 'dangling';
88
+ throw error;
89
+ }
90
+ }
91
+ /** Thrown when a following walk is led back into a directory it has already entered. */
92
+ export class DirectoryWalkRevisitedError extends VatError {
93
+ constructor(dir, enteredAs) {
94
+ super('DIRECTORY_WALK_REVISITED', `Refusing to enter ${dir}: it is the directory already walked as ${enteredAs} — a symlink leads the walk back into itself.`);
95
+ }
96
+ }
97
+ /**
98
+ * The directories a FOLLOWING walk has entered, by realpath, so a link that
99
+ * leads back into the walk is refused instead of recursed until
100
+ * `ENAMETOOLONG`.
101
+ *
102
+ * A walk that follows links (`direntKindFollowing`) has no cycle guard by
103
+ * construction: `scripts/loop -> .` makes a copy create `dest/loop/loop/…`,
104
+ * writing every file at every level first, and a hashing walk do the same in
105
+ * memory. Every following walk holds one of these and calls {@link enter}
106
+ * on every directory it recurses into — the root included. A REVISIT is
107
+ * refused, not just a cycle: two links to one directory make the walk's
108
+ * output ambiguous (which spelling is the file's path?), and a refusal that
109
+ * names both spellings is the answer the author can act on.
110
+ */
111
+ export class FollowedWalk {
112
+ #entered = new Map();
113
+ /**
114
+ * Record that the walk is entering `dir`. Synchronous on purpose — one
115
+ * realpath per directory entered is nothing beside the listing itself, and
116
+ * the sync and async walkers then share one guard.
117
+ *
118
+ * @param dir - A directory the walk is about to list, in any spelling
119
+ * @throws {DirectoryWalkRevisitedError} When its realpath was entered before
120
+ */
121
+ enter(dir) {
122
+ const real = toForwardSlash(normalizePath(dir));
123
+ const before = this.#entered.get(real);
124
+ if (before !== undefined)
125
+ throw new DirectoryWalkRevisitedError(dir, before);
126
+ this.#entered.set(real, dir);
127
+ }
128
+ }
129
+ //# sourceMappingURL=dirent-kind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dirent-kind.js","sourceRoot":"","sources":["../src/dirent-kind.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The one answer to "am I the script Node was asked to run?"
3
+ *
4
+ * Lives here, in the package every other one already depends on, because there
5
+ * has to be exactly one: the question was previously answered three different
6
+ * ways across this monorepo (`import.meta.main`, a raw `pathToFileURL` string
7
+ * compare, and this function), and two of those three answer `false` for the
8
+ * script they guard — which makes the process exit 0 having run nothing.
9
+ */
10
+ /**
11
+ * Was this module invoked as the process entrypoint, rather than imported?
12
+ *
13
+ * ⛔ **Do not reach for `import.meta.main` here.** It shipped in Node 24.2 and
14
+ * 22.18; this repo declares a floor of `>=22.13.0`, and on a real 22.13.0 the
15
+ * property is `undefined`:
16
+ *
17
+ * ```
18
+ * $ node-v22.13.0 --input-type=module -e "console.log(import.meta.main)" -> undefined
19
+ * ```
20
+ *
21
+ * That is not a cosmetic gap. Every `if (import.meta.main)` guard in the
22
+ * dev-tools package was silently dead on the declared floor — running
23
+ * `validate-repo-structure.ts` under 22.13.0 printed nothing and exited 0, so a
24
+ * contributor sitting exactly on the supported Node got a green pre-commit
25
+ * structure gate that had run no rule at all. Raising the floor to 22.18 would
26
+ * have hidden the defect behind the number instead of fixing it.
27
+ *
28
+ * ⛔ **Nor the raw string compare it replaced**, which was the other seven
29
+ * guards in this repo:
30
+ *
31
+ * ```ts
32
+ * import.meta.url === pathToFileURL(process.argv[1]).href // ❌
33
+ * ```
34
+ *
35
+ * A `node_modules/.bin` entry is a SYMLINK to the real script, so `argv[1]` is
36
+ * the link and `import.meta.url` is the resolved target. The two strings differ
37
+ * and the guard is false — measured false through a `.bin`-style symlink on both
38
+ * Node 22.14.0 and 24.13.1, where this function is true.
39
+ *
40
+ * `process.argv[1]` is defined on every Node this repo supports, so the
41
+ * comparison below is the portable form of the same question. The realpath pass
42
+ * covers the case where the script is reached through a symlink on one side of
43
+ * the comparison but not the other.
44
+ *
45
+ * `local/no-fragile-entrypoint-guard` (shipped on this package's `./eslint`
46
+ * subpath) is what keeps either of the two banned spellings from coming back —
47
+ * the prose comments that used to stand in for it did not.
48
+ *
49
+ * @param importMetaUrl - The calling module's `import.meta.url`
50
+ * @param entryPath - The invoked script path; defaults to `process.argv[1]`
51
+ * @returns `true` only when this module is the script Node was asked to run
52
+ */
53
+ export declare function isEntrypoint(importMetaUrl: string, entryPath?: string | undefined): boolean;
54
+ //# sourceMappingURL=entrypoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entrypoint.d.ts","sourceRoot":"","sources":["../src/entrypoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,YAAY,CAC1B,aAAa,EAAE,MAAM,EACrB,SAAS,GAAE,MAAM,GAAG,SAA2B,GAC9C,OAAO,CAcT"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one answer to "am I the script Node was asked to run?"
3
+ *
4
+ * Lives here, in the package every other one already depends on, because there
5
+ * has to be exactly one: the question was previously answered three different
6
+ * ways across this monorepo (`import.meta.main`, a raw `pathToFileURL` string
7
+ * compare, and this function), and two of those three answer `false` for the
8
+ * script they guard — which makes the process exit 0 having run nothing.
9
+ */
10
+ import { fileURLToPath } from 'node:url';
11
+ import { safePath, toForwardSlash } from './path-core.js';
12
+ import { normalizePath } from './path-utils.js';
13
+ /**
14
+ * Was this module invoked as the process entrypoint, rather than imported?
15
+ *
16
+ * ⛔ **Do not reach for `import.meta.main` here.** It shipped in Node 24.2 and
17
+ * 22.18; this repo declares a floor of `>=22.13.0`, and on a real 22.13.0 the
18
+ * property is `undefined`:
19
+ *
20
+ * ```
21
+ * $ node-v22.13.0 --input-type=module -e "console.log(import.meta.main)" -> undefined
22
+ * ```
23
+ *
24
+ * That is not a cosmetic gap. Every `if (import.meta.main)` guard in the
25
+ * dev-tools package was silently dead on the declared floor — running
26
+ * `validate-repo-structure.ts` under 22.13.0 printed nothing and exited 0, so a
27
+ * contributor sitting exactly on the supported Node got a green pre-commit
28
+ * structure gate that had run no rule at all. Raising the floor to 22.18 would
29
+ * have hidden the defect behind the number instead of fixing it.
30
+ *
31
+ * ⛔ **Nor the raw string compare it replaced**, which was the other seven
32
+ * guards in this repo:
33
+ *
34
+ * ```ts
35
+ * import.meta.url === pathToFileURL(process.argv[1]).href // ❌
36
+ * ```
37
+ *
38
+ * A `node_modules/.bin` entry is a SYMLINK to the real script, so `argv[1]` is
39
+ * the link and `import.meta.url` is the resolved target. The two strings differ
40
+ * and the guard is false — measured false through a `.bin`-style symlink on both
41
+ * Node 22.14.0 and 24.13.1, where this function is true.
42
+ *
43
+ * `process.argv[1]` is defined on every Node this repo supports, so the
44
+ * comparison below is the portable form of the same question. The realpath pass
45
+ * covers the case where the script is reached through a symlink on one side of
46
+ * the comparison but not the other.
47
+ *
48
+ * `local/no-fragile-entrypoint-guard` (shipped on this package's `./eslint`
49
+ * subpath) is what keeps either of the two banned spellings from coming back —
50
+ * the prose comments that used to stand in for it did not.
51
+ *
52
+ * @param importMetaUrl - The calling module's `import.meta.url`
53
+ * @param entryPath - The invoked script path; defaults to `process.argv[1]`
54
+ * @returns `true` only when this module is the script Node was asked to run
55
+ */
56
+ export function isEntrypoint(importMetaUrl, entryPath = process.argv[1]) {
57
+ // An empty argv[1] would resolve to the cwd and could then match a module by
58
+ // accident; `undefined` happens under `node -e`. Neither is an entrypoint.
59
+ if (entryPath === undefined || entryPath === '')
60
+ return false;
61
+ const modulePath = safePath.resolve(fileURLToPath(importMetaUrl));
62
+ const invokedPath = safePath.resolve(entryPath);
63
+ if (modulePath === invokedPath)
64
+ return true;
65
+ // `normalizePath` resolves symlinks and Windows 8.3 short names, and returns
66
+ // the path unchanged when it cannot — which is the right answer here, since
67
+ // the string comparison above has already decided the two differ and an
68
+ // unresolvable path carries no symlink information to compare.
69
+ return toForwardSlash(normalizePath(modulePath)) === toForwardSlash(normalizePath(invokedPath));
70
+ }
71
+ //# sourceMappingURL=entrypoint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entrypoint.js","sourceRoot":"","sources":["../src/entrypoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,UAAU,YAAY,CAC1B,aAAqB,EACrB,YAAgC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IAE/C,6EAA6E;IAC7E,2EAA2E;IAC3E,IAAI,SAAS,KAAK,SAAS,IAAI,SAAS,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAE9D,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC;IAClE,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChD,IAAI,UAAU,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IAE5C,6EAA6E;IAC7E,4EAA4E;IAC5E,wEAAwE;IACxE,+DAA+D;IAC/D,OAAO,cAAc,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,KAAK,cAAc,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC,CAAC;AAClG,CAAC"}
@@ -0,0 +1,46 @@
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
+ /**
35
+ * Parse an env value as a boolean.
36
+ *
37
+ * Case-insensitive and surrounding whitespace is trimmed, because a value that
38
+ * arrived through a shell, a CI YAML block, or a `.env` file routinely carries
39
+ * both. Everything else — including the empty string, which is what an unset
40
+ * shell variable expands to — returns `undefined`.
41
+ *
42
+ * @param raw - The raw env value, or `undefined` when the variable is unset
43
+ * @returns `true` / `false` for a recognised spelling; `undefined` otherwise
44
+ */
45
+ export declare function parseEnvBoolean(raw: string | undefined): boolean | undefined;
46
+ //# sourceMappingURL=env-flag.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env-flag.d.ts","sourceRoot":"","sources":["../src/env-flag.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAKH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAM5E"}