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

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 (330) hide show
  1. package/README.md +112 -10
  2. package/dist/archive/archive.d.ts +129 -0
  3. package/dist/archive/archive.d.ts.map +1 -0
  4. package/dist/archive/archive.js +223 -0
  5. package/dist/archive/archive.js.map +1 -0
  6. package/dist/archive/entries.d.ts +132 -0
  7. package/dist/archive/entries.d.ts.map +1 -0
  8. package/dist/archive/entries.js +223 -0
  9. package/dist/archive/entries.js.map +1 -0
  10. package/dist/archive/limits.d.ts +39 -0
  11. package/dist/archive/limits.d.ts.map +1 -0
  12. package/dist/archive/limits.js +59 -0
  13. package/dist/archive/limits.js.map +1 -0
  14. package/dist/archive/records.d.ts +14 -0
  15. package/dist/archive/records.d.ts.map +1 -0
  16. package/dist/archive/records.js +3 -0
  17. package/dist/archive/records.js.map +1 -0
  18. package/dist/archive/tar-reader.d.ts +18 -0
  19. package/dist/archive/tar-reader.d.ts.map +1 -0
  20. package/dist/archive/tar-reader.js +173 -0
  21. package/dist/archive/tar-reader.js.map +1 -0
  22. package/dist/archive/zip-reader.d.ts +76 -0
  23. package/dist/archive/zip-reader.d.ts.map +1 -0
  24. package/dist/archive/zip-reader.js +376 -0
  25. package/dist/archive/zip-reader.js.map +1 -0
  26. package/dist/archive.d.ts +14 -0
  27. package/dist/archive.d.ts.map +1 -0
  28. package/dist/archive.js +12 -0
  29. package/dist/archive.js.map +1 -0
  30. package/dist/asset-reference.d.ts +17 -1
  31. package/dist/asset-reference.d.ts.map +1 -1
  32. package/dist/asset-reference.js +31 -7
  33. package/dist/asset-reference.js.map +1 -1
  34. package/dist/dirent-kind.d.ts +13 -40
  35. package/dist/dirent-kind.d.ts.map +1 -1
  36. package/dist/dirent-kind.js +21 -69
  37. package/dist/dirent-kind.js.map +1 -1
  38. package/dist/entrypoint.d.ts +2 -2
  39. package/dist/entrypoint.d.ts.map +1 -1
  40. package/dist/entrypoint.js +4 -3
  41. package/dist/entrypoint.js.map +1 -1
  42. package/dist/env-flag.d.ts +13 -5
  43. package/dist/env-flag.d.ts.map +1 -1
  44. package/dist/env-flag.js +13 -5
  45. package/dist/env-flag.js.map +1 -1
  46. package/dist/errors/confirmed-absent.d.ts +41 -0
  47. package/dist/errors/confirmed-absent.d.ts.map +1 -0
  48. package/dist/errors/confirmed-absent.js +43 -0
  49. package/dist/errors/confirmed-absent.js.map +1 -0
  50. package/dist/errors/errno-table.d.ts +177 -0
  51. package/dist/errors/errno-table.d.ts.map +1 -0
  52. package/dist/errors/errno-table.js +343 -0
  53. package/dist/errors/errno-table.js.map +1 -0
  54. package/dist/errors/fs-boundary.d.ts +36 -0
  55. package/dist/errors/fs-boundary.d.ts.map +1 -0
  56. package/dist/errors/fs-boundary.js +121 -0
  57. package/dist/errors/fs-boundary.js.map +1 -0
  58. package/dist/errors/fs-fault.d.ts +176 -0
  59. package/dist/errors/fs-fault.d.ts.map +1 -0
  60. package/dist/errors/fs-fault.js +215 -0
  61. package/dist/errors/fs-fault.js.map +1 -0
  62. package/dist/errors/git-errno.d.ts +13 -0
  63. package/dist/errors/git-errno.d.ts.map +1 -0
  64. package/dist/errors/git-errno.js +45 -0
  65. package/dist/errors/git-errno.js.map +1 -0
  66. package/dist/errors/path-present.d.ts +72 -0
  67. package/dist/errors/path-present.d.ts.map +1 -0
  68. package/dist/errors/path-present.js +110 -0
  69. package/dist/errors/path-present.js.map +1 -0
  70. package/dist/errors/rag-index-empty.d.ts +16 -0
  71. package/dist/errors/rag-index-empty.d.ts.map +1 -0
  72. package/dist/errors/rag-index-empty.js +16 -0
  73. package/dist/errors/rag-index-empty.js.map +1 -0
  74. package/dist/errors/suppressed-faults.d.ts +34 -0
  75. package/dist/errors/suppressed-faults.d.ts.map +1 -0
  76. package/dist/errors/suppressed-faults.js +66 -0
  77. package/dist/errors/suppressed-faults.js.map +1 -0
  78. package/dist/file-crawler.d.ts +13 -18
  79. package/dist/file-crawler.d.ts.map +1 -1
  80. package/dist/file-crawler.js +140 -24
  81. package/dist/file-crawler.js.map +1 -1
  82. package/dist/fs-utils.d.ts +75 -50
  83. package/dist/fs-utils.d.ts.map +1 -1
  84. package/dist/fs-utils.js +148 -158
  85. package/dist/fs-utils.js.map +1 -1
  86. package/dist/fs.d.ts +5 -4
  87. package/dist/fs.d.ts.map +1 -1
  88. package/dist/fs.js +5 -4
  89. package/dist/fs.js.map +1 -1
  90. package/dist/git-snapshot.d.ts +25 -17
  91. package/dist/git-snapshot.d.ts.map +1 -1
  92. package/dist/git-snapshot.js +65 -11
  93. package/dist/git-snapshot.js.map +1 -1
  94. package/dist/git-tracker.d.ts +11 -3
  95. package/dist/git-tracker.d.ts.map +1 -1
  96. package/dist/git-tracker.js +29 -6
  97. package/dist/git-tracker.js.map +1 -1
  98. package/dist/git-utils.d.ts.map +1 -1
  99. package/dist/git-utils.js +7 -35
  100. package/dist/git-utils.js.map +1 -1
  101. package/dist/git.d.ts +1 -1
  102. package/dist/git.d.ts.map +1 -1
  103. package/dist/git.js +1 -1
  104. package/dist/git.js.map +1 -1
  105. package/dist/gitignore-checker.d.ts.map +1 -1
  106. package/dist/gitignore-checker.js +5 -4
  107. package/dist/gitignore-checker.js.map +1 -1
  108. package/dist/in-order.d.ts +93 -0
  109. package/dist/in-order.d.ts.map +1 -0
  110. package/dist/in-order.js +165 -0
  111. package/dist/in-order.js.map +1 -0
  112. package/dist/index.d.ts +20 -4
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +30 -4
  115. package/dist/index.js.map +1 -1
  116. package/dist/kill-process-tree.d.ts +43 -0
  117. package/dist/kill-process-tree.d.ts.map +1 -0
  118. package/dist/kill-process-tree.js +48 -0
  119. package/dist/kill-process-tree.js.map +1 -0
  120. package/dist/link-following.d.ts +115 -0
  121. package/dist/link-following.d.ts.map +1 -0
  122. package/dist/link-following.js +283 -0
  123. package/dist/link-following.js.map +1 -0
  124. package/dist/listing-refusal.d.ts +44 -0
  125. package/dist/listing-refusal.d.ts.map +1 -1
  126. package/dist/listing-refusal.js +79 -2
  127. package/dist/listing-refusal.js.map +1 -1
  128. package/dist/name-fold.d.ts +25 -0
  129. package/dist/name-fold.d.ts.map +1 -0
  130. package/dist/name-fold.js +40 -0
  131. package/dist/name-fold.js.map +1 -0
  132. package/dist/path-containment.d.ts +17 -0
  133. package/dist/path-containment.d.ts.map +1 -1
  134. package/dist/path-containment.js +28 -5
  135. package/dist/path-containment.js.map +1 -1
  136. package/dist/path-utils.d.ts +14 -17
  137. package/dist/path-utils.d.ts.map +1 -1
  138. package/dist/path-utils.js +37 -55
  139. package/dist/path-utils.js.map +1 -1
  140. package/dist/process.d.ts +2 -0
  141. package/dist/process.d.ts.map +1 -1
  142. package/dist/process.js +2 -0
  143. package/dist/process.js.map +1 -1
  144. package/dist/project-utils.d.ts.map +1 -1
  145. package/dist/project-utils.js +8 -9
  146. package/dist/project-utils.js.map +1 -1
  147. package/dist/safe-exec.d.ts +6 -2
  148. package/dist/safe-exec.d.ts.map +1 -1
  149. package/dist/safe-exec.js +1 -1
  150. package/dist/safe-exec.js.map +1 -1
  151. package/dist/skill-targets.d.ts.map +1 -1
  152. package/dist/skill-targets.js +3 -1
  153. package/dist/skill-targets.js.map +1 -1
  154. package/dist/skill-test/spawn-claude.d.ts +0 -15
  155. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  156. package/dist/skill-test/spawn-claude.js +11 -50
  157. package/dist/skill-test/spawn-claude.js.map +1 -1
  158. package/dist/special-file.d.ts +18 -0
  159. package/dist/special-file.d.ts.map +1 -0
  160. package/dist/special-file.js +21 -0
  161. package/dist/special-file.js.map +1 -0
  162. package/dist/terminating-signals.d.ts +129 -0
  163. package/dist/terminating-signals.d.ts.map +1 -0
  164. package/dist/terminating-signals.js +210 -0
  165. package/dist/terminating-signals.js.map +1 -0
  166. package/dist/test-helpers.d.ts +9 -5
  167. package/dist/test-helpers.d.ts.map +1 -1
  168. package/dist/test-helpers.js +18 -21
  169. package/dist/test-helpers.js.map +1 -1
  170. package/dist/test-scratch-guard.d.ts +34 -0
  171. package/dist/test-scratch-guard.d.ts.map +1 -0
  172. package/dist/test-scratch-guard.js +45 -0
  173. package/dist/test-scratch-guard.js.map +1 -0
  174. package/dist/testing/archive-fixtures.d.ts +147 -0
  175. package/dist/testing/archive-fixtures.d.ts.map +1 -0
  176. package/dist/testing/archive-fixtures.js +449 -0
  177. package/dist/testing/archive-fixtures.js.map +1 -0
  178. package/dist/testing/executables.d.ts +6 -0
  179. package/dist/testing/executables.d.ts.map +1 -1
  180. package/dist/testing/executables.js +20 -9
  181. package/dist/testing/executables.js.map +1 -1
  182. package/dist/testing/fault-fs-preload.d.ts +16 -0
  183. package/dist/testing/fault-fs-preload.d.ts.map +1 -0
  184. package/dist/testing/fault-fs-preload.js +22 -0
  185. package/dist/testing/fault-fs-preload.js.map +1 -0
  186. package/dist/testing/fault-fs.d.ts +115 -0
  187. package/dist/testing/fault-fs.d.ts.map +1 -0
  188. package/dist/testing/fault-fs.js +411 -0
  189. package/dist/testing/fault-fs.js.map +1 -0
  190. package/dist/testing/fault-spec.d.ts +83 -0
  191. package/dist/testing/fault-spec.d.ts.map +1 -0
  192. package/dist/testing/fault-spec.js +132 -0
  193. package/dist/testing/fault-spec.js.map +1 -0
  194. package/dist/testing/hostile-tree.d.ts.map +1 -1
  195. package/dist/testing/hostile-tree.js +3 -4
  196. package/dist/testing/hostile-tree.js.map +1 -1
  197. package/dist/testing/platform-gates.d.ts +8 -0
  198. package/dist/testing/platform-gates.d.ts.map +1 -1
  199. package/dist/testing/platform-gates.js +24 -0
  200. package/dist/testing/platform-gates.js.map +1 -1
  201. package/dist/testing/temp-dir.d.ts +31 -0
  202. package/dist/testing/temp-dir.d.ts.map +1 -1
  203. package/dist/testing/temp-dir.js +63 -0
  204. package/dist/testing/temp-dir.js.map +1 -1
  205. package/dist/testing/tier-timeout.d.ts +34 -0
  206. package/dist/testing/tier-timeout.d.ts.map +1 -0
  207. package/dist/testing/tier-timeout.js +45 -0
  208. package/dist/testing/tier-timeout.js.map +1 -0
  209. package/dist/testing/tree-snapshot.d.ts +71 -0
  210. package/dist/testing/tree-snapshot.d.ts.map +1 -0
  211. package/dist/testing/tree-snapshot.js +163 -0
  212. package/dist/testing/tree-snapshot.js.map +1 -0
  213. package/dist/testing.d.ts +6 -0
  214. package/dist/testing.d.ts.map +1 -1
  215. package/dist/testing.js +8 -1
  216. package/dist/testing.js.map +1 -1
  217. package/dist/text-content.d.ts +29 -0
  218. package/dist/text-content.d.ts.map +1 -1
  219. package/dist/text-content.js +64 -0
  220. package/dist/text-content.js.map +1 -1
  221. package/dist/text-file.d.ts +58 -6
  222. package/dist/text-file.d.ts.map +1 -1
  223. package/dist/text-file.js +111 -11
  224. package/dist/text-file.js.map +1 -1
  225. package/dist/text.d.ts +1 -1
  226. package/dist/text.d.ts.map +1 -1
  227. package/dist/text.js +1 -1
  228. package/dist/text.js.map +1 -1
  229. package/dist/timing-dump.d.ts.map +1 -1
  230. package/dist/timing-dump.js +2 -10
  231. package/dist/timing-dump.js.map +1 -1
  232. package/dist/tree-change/apply-decisions.d.ts +101 -0
  233. package/dist/tree-change/apply-decisions.d.ts.map +1 -0
  234. package/dist/tree-change/apply-decisions.js +139 -0
  235. package/dist/tree-change/apply-decisions.js.map +1 -0
  236. package/dist/tree-change/apply.d.ts +98 -0
  237. package/dist/tree-change/apply.d.ts.map +1 -0
  238. package/dist/tree-change/apply.js +364 -0
  239. package/dist/tree-change/apply.js.map +1 -0
  240. package/dist/tree-change/copy-decisions.d.ts +58 -0
  241. package/dist/tree-change/copy-decisions.d.ts.map +1 -0
  242. package/dist/tree-change/copy-decisions.js +66 -0
  243. package/dist/tree-change/copy-decisions.js.map +1 -0
  244. package/dist/tree-change/copy-tree.d.ts +125 -0
  245. package/dist/tree-change/copy-tree.d.ts.map +1 -0
  246. package/dist/tree-change/copy-tree.js +279 -0
  247. package/dist/tree-change/copy-tree.js.map +1 -0
  248. package/dist/tree-change/files.d.ts +167 -0
  249. package/dist/tree-change/files.d.ts.map +1 -0
  250. package/dist/tree-change/files.js +423 -0
  251. package/dist/tree-change/files.js.map +1 -0
  252. package/dist/tree-change/followed-walk.d.ts +54 -0
  253. package/dist/tree-change/followed-walk.d.ts.map +1 -0
  254. package/dist/tree-change/followed-walk.js +81 -0
  255. package/dist/tree-change/followed-walk.js.map +1 -0
  256. package/dist/tree-change/host-chain.d.ts +48 -0
  257. package/dist/tree-change/host-chain.d.ts.map +1 -0
  258. package/dist/tree-change/host-chain.js +85 -0
  259. package/dist/tree-change/host-chain.js.map +1 -0
  260. package/dist/tree-change/identity-compare.d.ts +61 -0
  261. package/dist/tree-change/identity-compare.d.ts.map +1 -0
  262. package/dist/tree-change/identity-compare.js +115 -0
  263. package/dist/tree-change/identity-compare.js.map +1 -0
  264. package/dist/tree-change/identity.d.ts +62 -0
  265. package/dist/tree-change/identity.d.ts.map +1 -0
  266. package/dist/tree-change/identity.js +152 -0
  267. package/dist/tree-change/identity.js.map +1 -0
  268. package/dist/tree-change/index.d.ts +24 -0
  269. package/dist/tree-change/index.d.ts.map +1 -0
  270. package/dist/tree-change/index.js +14 -0
  271. package/dist/tree-change/index.js.map +1 -0
  272. package/dist/tree-change/link-chain.d.ts +180 -0
  273. package/dist/tree-change/link-chain.d.ts.map +1 -0
  274. package/dist/tree-change/link-chain.js +377 -0
  275. package/dist/tree-change/link-chain.js.map +1 -0
  276. package/dist/tree-change/link-containment.d.ts +105 -0
  277. package/dist/tree-change/link-containment.d.ts.map +1 -0
  278. package/dist/tree-change/link-containment.js +196 -0
  279. package/dist/tree-change/link-containment.js.map +1 -0
  280. package/dist/tree-change/link-walk.d.ts +49 -0
  281. package/dist/tree-change/link-walk.d.ts.map +1 -0
  282. package/dist/tree-change/link-walk.js +69 -0
  283. package/dist/tree-change/link-walk.js.map +1 -0
  284. package/dist/tree-change/plan.d.ts +206 -0
  285. package/dist/tree-change/plan.d.ts.map +1 -0
  286. package/dist/tree-change/plan.js +320 -0
  287. package/dist/tree-change/plan.js.map +1 -0
  288. package/dist/tree-change/readable-tree.d.ts +44 -0
  289. package/dist/tree-change/readable-tree.d.ts.map +1 -0
  290. package/dist/tree-change/readable-tree.js +52 -0
  291. package/dist/tree-change/readable-tree.js.map +1 -0
  292. package/dist/tree-change/rollback-error.d.ts +29 -0
  293. package/dist/tree-change/rollback-error.d.ts.map +1 -0
  294. package/dist/tree-change/rollback-error.js +31 -0
  295. package/dist/tree-change/rollback-error.js.map +1 -0
  296. package/dist/tree-change/staging-names.d.ts +27 -0
  297. package/dist/tree-change/staging-names.d.ts.map +1 -0
  298. package/dist/tree-change/staging-names.js +40 -0
  299. package/dist/tree-change/staging-names.js.map +1 -0
  300. package/dist/tree-change/tree-walk.d.ts +81 -0
  301. package/dist/tree-change/tree-walk.d.ts.map +1 -0
  302. package/dist/tree-change/tree-walk.js +212 -0
  303. package/dist/tree-change/tree-walk.js.map +1 -0
  304. package/dist/win32-namespace.d.ts +24 -0
  305. package/dist/win32-namespace.d.ts.map +1 -0
  306. package/dist/win32-namespace.js +31 -0
  307. package/dist/win32-namespace.js.map +1 -0
  308. package/dist/yaml/surgical-yaml.d.ts +12 -3
  309. package/dist/yaml/surgical-yaml.d.ts.map +1 -1
  310. package/dist/yaml/surgical-yaml.js +16 -6
  311. package/dist/yaml/surgical-yaml.js.map +1 -1
  312. package/eslint/README.md +12 -4
  313. package/eslint/rules/fs-binding-scan.cjs +278 -0
  314. package/eslint/rules/no-adhoc-errno.cjs +364 -0
  315. package/eslint/rules/no-bare-symlink-in-tests.cjs +2 -3
  316. package/eslint/rules/no-blind-catch.cjs +1 -1
  317. package/eslint/rules/no-destructive-fs.cjs +359 -0
  318. package/eslint/rules/no-existssync.cjs +70 -0
  319. package/eslint/rules/no-fragile-entrypoint-guard.cjs +3 -2
  320. package/eslint/rules/no-link-following-fs.cjs +68 -0
  321. package/eslint/rules/no-literal-process-exit.cjs +96 -8
  322. package/eslint/rules/no-literal-test-timeout.cjs +214 -0
  323. package/eslint/rules/no-raw-signal-listener.cjs +224 -0
  324. package/eslint/rules/no-stdout-outside-writer.cjs +135 -0
  325. package/package.json +10 -2
  326. package/dist/errors/errno.d.ts +0 -51
  327. package/dist/errors/errno.d.ts.map +0 -1
  328. package/dist/errors/errno.js +0 -112
  329. package/dist/errors/errno.js.map +0 -1
  330. package/eslint/rules/no-fs-promises-cp.cjs +0 -43
@@ -0,0 +1,359 @@
1
+ /**
2
+ * ESLint rule: no-destructive-fs
3
+ *
4
+ * Bans every recursive remove, rename and copy of a filesystem tree outside the one
5
+ * module that owns them. A hand-rolled `rm` + `rename` + `cp` sequence is a private
6
+ * swap protocol, and each one decided differently what happens when the disk fills, a
7
+ * file is unreadable, a name differs only by case, or the process dies between two
8
+ * steps. The tree-change primitive (`planTreeChanges` / `applyTreePlan`, plus
9
+ * `replaceFile`, `renameFileAtomic`, `withTempDir`, `disposeTempDir`, `copyTree`) is
10
+ * the single protocol; everything else asks it.
11
+ *
12
+ * The rule judges REFERENCES to a banned function, not only calls, using the scope
13
+ * manager. A binding is banned when it comes from `node:fs` / `node:fs/promises` (or
14
+ * the unprefixed names) by named import (`import { rmSync as r }`), by destructuring a
15
+ * namespace (`const { rmSync } = fs`, `const { promises: { rm } } = fs`), or by a
16
+ * dynamic `import()` / `require()`; a namespace is a default or namespace import, a
17
+ * `promises` binding, `fs.promises`, an alias of one, or a dynamic `import()` /
18
+ * `require()`. A local that shadows an imported name is not the fs function.
19
+ *
20
+ * - `recursiveRm` — `rm` / `rmSync`, unless the reference is a call whose options are
21
+ * provably non-recursive: an object literal with no `recursive` key or `recursive:
22
+ * false`. An identifier, a call, a spread (`rmSync(...args)`), a computed key, a
23
+ * non-literal value, or a use as a value (`names.map(rmSync)`, `.call`, `.apply`)
24
+ * counts as recursive. `rm(file)` and `rm(file, { force: true })` are one file.
25
+ * - `rmdir`, `rename`, `cp`, `copyFile` — every reference: a call, a value use
26
+ * (`names.map(fs.renameSync)`, `fs['renameSync']`), `.call` / `.apply` / `.bind`, a
27
+ * re-export (`export { renameSync } from 'node:fs'`). A file rename goes through
28
+ * `renameFileAtomic`, which owns the Windows retry.
29
+ * - `reexport` — `export * from 'node:fs'`, which hands every banned function on.
30
+ *
31
+ * `unlink` is allowed: removing one file is not a tree change.
32
+ *
33
+ * Exempt: test files, and the entries of `exemptFiles`. An entry ending in `/` is a
34
+ * DIRECTORY (anchored at a path-segment boundary: the primitive's directory, the test
35
+ * infrastructure that lives in `src`); any other entry is one file. A bare name (`x.ts`
36
+ * or `tree-change/`) is reported as unanchored, because it would exempt that name in
37
+ * every package. No default exemption ships, so a consumer declares its own.
38
+ */
39
+
40
+ const {
41
+ EXEMPT_FILES_SCHEMA,
42
+ UNANCHORED_EXEMPT_FILE,
43
+ UNANCHORED_EXEMPT_MESSAGE,
44
+ createExemptDirectoryMatcher,
45
+ createExemptPathMatcher,
46
+ isTestFile,
47
+ normalizeForMatch,
48
+ reportUnanchoredExemptEntries,
49
+ } = require('./exempt-path-matcher.cjs');
50
+
51
+ const FS_MODULES = new Set(['fs', 'node:fs', 'fs/promises', 'node:fs/promises']);
52
+
53
+ /** `fs` function name -> the messageId that reports it. `rm` is judged by its options. */
54
+ const MESSAGE_FOR = new Map([
55
+ ['rm', 'recursiveRm'],
56
+ ['rmSync', 'recursiveRm'],
57
+ ['rmdir', 'rmdir'],
58
+ ['rmdirSync', 'rmdir'],
59
+ ['rename', 'rename'],
60
+ ['renameSync', 'rename'],
61
+ ['cp', 'cp'],
62
+ ['cpSync', 'cp'],
63
+ ['copyFile', 'copyFile'],
64
+ ['copyFileSync', 'copyFile'],
65
+ ]);
66
+
67
+ const UNANCHORED_EXEMPT_DIRECTORY = 'unanchoredExemptDirectory';
68
+ const FUNCTION_TYPES = new Set(['FunctionExpression', 'ArrowFunctionExpression']);
69
+ /** Wrappers that pass a value through unchanged. */
70
+ const TRANSPARENT_TYPES = new Set(['AwaitExpression', 'TSAsExpression', 'TSNonNullExpression', 'TSSatisfiesExpression']);
71
+
72
+ /** The string a property key or member property spells, or undefined when not static. */
73
+ function staticName(node, computed) {
74
+ if (!computed) {
75
+ return node.type === 'Identifier' ? node.name : undefined;
76
+ }
77
+ return node.type === 'Literal' && typeof node.value === 'string' ? node.value : undefined;
78
+ }
79
+
80
+ /** True when `node` is a string literal naming an fs module. */
81
+ function isFsModuleLiteral(node) {
82
+ return node?.type === 'Literal' && FS_MODULES.has(node.value);
83
+ }
84
+
85
+ /**
86
+ * True when the `rm` options (the call's arguments) are, or may be, recursive. Only an
87
+ * object literal that provably does not turn recursion on is safe.
88
+ */
89
+ function mayBeRecursive(args) {
90
+ if (args.slice(0, 2).some((argument) => argument.type === 'SpreadElement')) {
91
+ return true;
92
+ }
93
+ const options = args[1];
94
+ if (options === undefined || FUNCTION_TYPES.has(options.type)) {
95
+ return false;
96
+ }
97
+ if (options.type !== 'ObjectExpression') {
98
+ return true;
99
+ }
100
+ return options.properties.some((property) => {
101
+ if (property.type !== 'Property') {
102
+ return true;
103
+ }
104
+ const key = staticName(property.key, property.computed);
105
+ if (key === undefined) {
106
+ return true;
107
+ }
108
+ return key === 'recursive' && !(property.value.type === 'Literal' && property.value.value === false);
109
+ });
110
+ }
111
+
112
+ /** `require('fs')` or `import('fs')`: an expression whose value is an fs namespace (a promise, for import). */
113
+ function isDynamicFsSource(node) {
114
+ if (node.type === 'ImportExpression') {
115
+ return isFsModuleLiteral(node.source);
116
+ }
117
+ return (
118
+ node.type === 'CallExpression' &&
119
+ node.callee.type === 'Identifier' &&
120
+ node.callee.name === 'require' &&
121
+ isFsModuleLiteral(node.arguments[0])
122
+ );
123
+ }
124
+
125
+ /** The directory entries of `exemptFiles` that name no parent: `tree-change/`. */
126
+ function bareDirectoryEntries(exemptFiles) {
127
+ return exemptFiles.filter((entry) => {
128
+ const normalized = normalizeForMatch(entry).replace(/^\/+/, '');
129
+ return normalized.endsWith('/') && !normalized.slice(0, -1).includes('/');
130
+ });
131
+ }
132
+
133
+ /** Match each exemption entry to the matcher its shape asks for. */
134
+ function createExemptMatcher(exemptFiles) {
135
+ const normalized = exemptFiles.map((entry) => normalizeForMatch(entry));
136
+ const isUnderDirectory = createExemptDirectoryMatcher(normalized.filter((entry) => entry.endsWith('/')));
137
+ const isFile = createExemptPathMatcher(normalized.filter((entry) => !entry.endsWith('/')));
138
+ return (filename) => isUnderDirectory(filename) || isFile(filename);
139
+ }
140
+
141
+ /**
142
+ * Judges every use of the fs names in one file, through the scope manager.
143
+ *
144
+ * `namespaces` holds the variables whose value is an fs namespace; `banned` maps a
145
+ * variable to the fs function name it holds. Both grow as destructures and aliases are
146
+ * followed, and each variable is judged once.
147
+ */
148
+ class FsUseJudge {
149
+ constructor(context) {
150
+ this.context = context;
151
+ this.sourceCode = context.sourceCode;
152
+ this.namespaces = new Set();
153
+ this.banned = new Map();
154
+ this.pending = [];
155
+ }
156
+
157
+ /** Seed from the imports and the dynamic sources, then follow every binding to a fixed point. */
158
+ run(programNode, dynamicSources) {
159
+ for (const statement of programNode.body) {
160
+ if (statement.type === 'ImportDeclaration' && isFsModuleLiteral(statement.source)) {
161
+ for (const specifier of statement.specifiers) this.seedImport(specifier);
162
+ }
163
+ }
164
+ for (const source of dynamicSources) this.useNamespace(source);
165
+ while (this.pending.length > 0) {
166
+ this.followReferences(this.pending.pop());
167
+ }
168
+ }
169
+
170
+ seedImport(specifier) {
171
+ const variable = this.sourceCode.getDeclaredVariables(specifier)[0];
172
+ const imported = specifier.type === 'ImportSpecifier' ? (specifier.imported.name ?? specifier.imported.value) : undefined;
173
+ if (imported === undefined || imported === 'promises') {
174
+ this.markNamespace(variable);
175
+ } else if (MESSAGE_FOR.has(imported)) {
176
+ this.markBanned(variable, imported);
177
+ }
178
+ }
179
+
180
+ followReferences(variable) {
181
+ const isNamespace = this.namespaces.has(variable);
182
+ for (const reference of variable.references.filter((candidate) => !candidate.init)) {
183
+ if (isNamespace) {
184
+ this.useNamespace(reference.identifier);
185
+ } else {
186
+ this.judgeBanned(reference.identifier, this.banned.get(variable));
187
+ }
188
+ }
189
+ }
190
+
191
+ markNamespace(variable) {
192
+ if (variable && !this.namespaces.has(variable)) {
193
+ this.namespaces.add(variable);
194
+ this.pending.push(variable);
195
+ }
196
+ }
197
+
198
+ markBanned(variable, name) {
199
+ if (variable && !this.banned.has(variable)) {
200
+ this.banned.set(variable, name);
201
+ this.pending.push(variable);
202
+ }
203
+ }
204
+
205
+ variableNamed(declaration, name) {
206
+ return this.sourceCode.getDeclaredVariables(declaration).find((variable) => variable.name === name);
207
+ }
208
+
209
+ /** Bind the names a destructuring pattern takes from an fs namespace. */
210
+ bindPattern(pattern, declaration) {
211
+ if (pattern.type === 'Identifier') {
212
+ this.markNamespace(this.variableNamed(declaration, pattern.name));
213
+ } else if (pattern.type === 'ObjectPattern') {
214
+ for (const property of pattern.properties) this.bindProperty(property, declaration);
215
+ }
216
+ }
217
+
218
+ bindProperty(property, declaration) {
219
+ if (property.type === 'RestElement') {
220
+ this.bindPattern(property.argument, declaration);
221
+ return;
222
+ }
223
+ const key = staticName(property.key, property.computed);
224
+ const target = property.value.type === 'AssignmentPattern' ? property.value.left : property.value;
225
+ if (key === 'promises') {
226
+ this.bindPattern(target, declaration);
227
+ } else if (MESSAGE_FOR.has(key) && target.type === 'Identifier') {
228
+ this.markBanned(this.variableNamed(declaration, target.name), key);
229
+ }
230
+ }
231
+
232
+ /** A banned function reached as `node`: report anything but a provably non-recursive `rm` call. */
233
+ judgeBanned(node, name) {
234
+ const parent = node.parent;
235
+ const isCall = parent.type === 'CallExpression' && parent.callee === node;
236
+ const isRm = MESSAGE_FOR.get(name) === 'recursiveRm';
237
+ if (isCall && isRm && !mayBeRecursive(parent.arguments)) {
238
+ return;
239
+ }
240
+ this.context.report({ node, messageId: MESSAGE_FOR.get(name), data: { name } });
241
+ }
242
+
243
+ /** `node` evaluates to an fs namespace: follow what its parent does with it. */
244
+ useNamespace(node) {
245
+ let current = node;
246
+ while (TRANSPARENT_TYPES.has(current.parent?.type)) {
247
+ current = current.parent;
248
+ }
249
+ const parent = current.parent;
250
+ if (parent?.type === 'MemberExpression' && parent.object === current) {
251
+ this.useMember(parent);
252
+ } else if (parent?.type === 'VariableDeclarator' && parent.init === current) {
253
+ this.bindPattern(parent.id, parent);
254
+ }
255
+ }
256
+
257
+ useMember(member) {
258
+ const name = staticName(member.property, member.computed);
259
+ if (name === 'promises') {
260
+ this.useNamespace(member);
261
+ } else if (MESSAGE_FOR.has(name)) {
262
+ this.judgeBanned(member, name);
263
+ }
264
+ }
265
+ }
266
+
267
+ module.exports = {
268
+ meta: {
269
+ type: 'problem',
270
+ docs: {
271
+ description:
272
+ 'Ban recursive rm, rmdir, rename, cp and copyFile outside the tree-change primitive: every tree change goes through one plan/apply protocol',
273
+ category: 'Filesystem and process',
274
+ bans: 'recursive `rm`/`rmSync`, `rmdir`, `rename`, `cp` and `copyFile` from `node:fs` / `node:fs/promises` (and their `Sync` forms), called or passed as values',
275
+ useInstead: '`planTreeChanges()` / `applyTreePlan()`, `renameFileAtomic()`, `replaceFile()`, `copyTree()`, `withTempDir()` / `disposeTempDir()`',
276
+ // Not in `recommended`: the remedy names this repo's own tree-change primitive,
277
+ // which an adopter does not have. VAT enables it explicitly, naming the
278
+ // primitive's directory and the test infrastructure that lives in `src`.
279
+ recommended: false,
280
+ recommendedSeverity: 'error',
281
+ },
282
+ fixable: null,
283
+ schema: [EXEMPT_FILES_SCHEMA],
284
+ messages: {
285
+ recursiveRm:
286
+ '{{name}} removes a tree, or may (its options are not a literal that rules recursion out). ' +
287
+ 'Plan the removal with planTreeChanges() / applyTreePlan(), or dispose a temp dir with withTempDir() / disposeTempDir(). ' +
288
+ 'Removing one file is fine: rm(file, { force: true }) or unlink().',
289
+ rmdir: '{{name}} is a tree removal. Use planTreeChanges() / applyTreePlan() (op: remove) or disposeTempDir().',
290
+ rename:
291
+ '{{name}} is half a swap protocol. Replace a tree with planTreeChanges() / applyTreePlan(); ' +
292
+ 'rename a single file with renameFileAtomic(), which owns the Windows retry.',
293
+ cp: '{{name}} copies a tree outside the primitive. Use copyTree(), or a replace change in planTreeChanges().',
294
+ copyFile: '{{name}} copies a file outside the primitive. Use copyTree() or replaceFile().',
295
+ reexport: 'Re-exporting every node:fs function hands on rm, rename, cp and copyFile. Export the names you need.',
296
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
297
+ [UNANCHORED_EXEMPT_DIRECTORY]:
298
+ 'exemptFiles entry "{{entry}}" is a bare directory name, so it exempts EVERY directory named ' +
299
+ '"{{entry}}" in the repo — including ones added later. Give the repo-relative path instead ' +
300
+ '(e.g. "packages/utils/src/{{entry}}").',
301
+ },
302
+ },
303
+
304
+ create(context) {
305
+ const filename = context.getFilename();
306
+ const configured = context.options?.[0]?.exemptFiles;
307
+ const exemptFiles = Array.isArray(configured) ? configured : [];
308
+ const isExempt = createExemptMatcher(exemptFiles);
309
+
310
+ /** Surface a malformed exemption list even for an exempt file: it may be exempt only BECAUSE of it. */
311
+ const reportExemptions = (node) => {
312
+ reportUnanchoredExemptEntries(context, node);
313
+ for (const entry of bareDirectoryEntries(exemptFiles)) {
314
+ context.report({ node, messageId: UNANCHORED_EXEMPT_DIRECTORY, data: { entry } });
315
+ }
316
+ };
317
+
318
+ if (isExempt(filename)) {
319
+ return { Program: reportExemptions };
320
+ }
321
+ if (isTestFile(filename)) {
322
+ return {};
323
+ }
324
+
325
+ const dynamicSources = [];
326
+
327
+ return {
328
+ Program: reportExemptions,
329
+
330
+ 'CallExpression, ImportExpression'(node) {
331
+ if (isDynamicFsSource(node)) {
332
+ dynamicSources.push(node);
333
+ }
334
+ },
335
+
336
+ ExportNamedDeclaration(node) {
337
+ if (!isFsModuleLiteral(node.source)) {
338
+ return;
339
+ }
340
+ for (const specifier of node.specifiers) {
341
+ const name = specifier.local.name ?? specifier.local.value;
342
+ if (MESSAGE_FOR.has(name)) {
343
+ context.report({ node: specifier, messageId: MESSAGE_FOR.get(name), data: { name } });
344
+ }
345
+ }
346
+ },
347
+
348
+ ExportAllDeclaration(node) {
349
+ if (isFsModuleLiteral(node.source)) {
350
+ context.report({ node, messageId: 'reexport' });
351
+ }
352
+ },
353
+
354
+ 'Program:exit'(node) {
355
+ new FsUseJudge(context).run(node, dynamicSources);
356
+ },
357
+ };
358
+ },
359
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ESLint rule: no-existssync
3
+ *
4
+ * In product source, flags every use of `existsSync` from `node:fs` / `fs`:
5
+ * a named import (aliased too) that is called or handed on as a value, a
6
+ * namespace, default, `require()` or `await import()` binding read as
7
+ * `fs.existsSync`, the same read straight off the loading expression
8
+ * (`require('node:fs').existsSync`, `(await import('node:fs')).existsSync`), a
9
+ * destructure of it out of any of those, and a re-export.
10
+ *
11
+ * `existsSync` answers `false` for `ENOENT` — and equally for `EACCES` (a
12
+ * parent the process may not search), `ELOOP` and every other errno. So a
13
+ * path the OS would not let VAT look at reads as "absent", and the run goes on
14
+ * as if it were: a LICENSE is skipped, a manifest is "not found", a plugin
15
+ * directory is missing. The replacement asks the one errno classifier:
16
+ * `pathPresent(path, mode, sideOf, absence)` from `@vibe-agent-toolkit/utils` answers
17
+ * `false` only for an absence, and throws every other errno as a classified
18
+ * `FS_FAULT` on the caller's side.
19
+ *
20
+ * ## Scope
21
+ *
22
+ * Product source: a file under `packages/<pkg>/src/` that is not itself a test
23
+ * file. Tests and test-support (`packages/<pkg>/test/`) are not linted — a
24
+ * fixture asking whether its own scratch file exists is not a run deciding
25
+ * what to do. Like `no-io-in-unit-tier`, the layout is this repo's, which is
26
+ * why the rule is not in `configs.recommended`.
27
+ *
28
+ * ## The floor — what this rule cannot see
29
+ *
30
+ * It follows bindings through scope, not values through data flow:
31
+ * - an fs namespace copied first (`const f = fs; f.existsSync(p)`) or passed
32
+ * as an argument;
33
+ * - `import('node:fs').then((fs) => fs.existsSync(p))`, and a `require`
34
+ * built by `createRequire`;
35
+ * - a project module that re-exports it under another name is caught at its
36
+ * own `export { existsSync } from 'node:fs'`, not at the importers of that
37
+ * module.
38
+ *
39
+ * Option `allowFiles: string[]` — repo-relative paths of today's offenders,
40
+ * the ratchet. Name files, never directories.
41
+ *
42
+ * @example
43
+ * // BAD — an EACCES parent reads as "no manifest"
44
+ * if (!existsSync(manifestPath)) return emptyInventory();
45
+ *
46
+ * // GOOD — absent is false; a refused stat throws a classified FS_FAULT
47
+ * if (!pathPresent(manifestPath, { follow: pluginDir }, everywhere('source'), 'probe')) return emptyInventory();
48
+ */
49
+
50
+ 'use strict';
51
+
52
+ const { productSourceFsBan } = require('./fs-binding-scan.cjs');
53
+
54
+ module.exports = productSourceFsBan({
55
+ docs: {
56
+ description:
57
+ 'Disallow existsSync from node:fs in product source — it answers false for a path it could not ' +
58
+ 'look at (EACCES, ELOOP) exactly as for one that is not there',
59
+ bans: '`existsSync()` from `node:fs` — called, handed on, destructured or re-exported — under `packages/*/src/`',
60
+ useInstead: '`pathPresent(path, mode, sideOf, absence)` (absent only on an absence; every other errno a classified `FS_FAULT`)',
61
+ },
62
+ messageId: 'existsSync',
63
+ message:
64
+ 'existsSync answers false for a path it could not look at (EACCES, ELOOP) exactly as for one that is ' +
65
+ 'not there, so "could not look" goes on as "absent". Use pathPresent(path, mode, sideOf, absence) from ' +
66
+ '@vibe-agent-toolkit/utils: false only for an absence, every other errno a classified FS_FAULT.',
67
+ modules: new Set(['node:fs', 'fs']),
68
+ banned: new Set(['existsSync']),
69
+ untyped: null,
70
+ });
@@ -25,7 +25,8 @@
25
25
  * every gate:
26
26
  *
27
27
  * - `import.meta.main` was added in Node **24.2 / 22.18**. Measured on the exact
28
- * floor this repo declares — `>=22.13.0` — the property is `undefined`:
28
+ * floor this repo declares — `>=22.16.0`, and on 22.13.0 before it — the
29
+ * property is `undefined`:
29
30
  *
30
31
  * ```
31
32
  * $ node-v22.13.0 --input-type=module -e "console.log(import.meta.main)" -> undefined
@@ -186,7 +187,7 @@ module.exports = {
186
187
  // Node floor rather than a portable fact. `import.meta.main` shipped in Node
187
188
  // 24.2 / 22.18; an adopter whose floor is at or above that writes it
188
189
  // correctly and would get a finding they cannot act on. This package's own
189
- // floor spans 22.13–22.17 where the property is `undefined`, so the hazard
190
+ // floor spans 22.16–22.17 where the property is `undefined`, so the hazard
190
191
  // is real for some adopters and absent for others, and only they know which.
191
192
  // The other half (`rawEntrypointCompare`) has no such dependency — a raw
192
193
  // string compare misses a symlinked entry on every Node there has ever been
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ESLint rule: no-link-following-fs
3
+ *
4
+ * In product source, flags every use of an fs call that FOLLOWS a symbolic link —
5
+ * `stat`, `statSync`, `existsSync`, `realpath`, `realpathSync` (and their
6
+ * `.native` forms), from `node:fs`, `fs`, `node:fs/promises` or `fs/promises`,
7
+ * however reached (see `fs-binding-scan.cjs`).
8
+ *
9
+ * A Windows kernel asked to follow a link to `\\host\share` opens an SMB session
10
+ * and offers the operator's NTLM credentials to that host before any answer comes
11
+ * back. So the kernel may be asked to follow a link only once the chain to it has
12
+ * been read by its text, hop by hop, and shown local — which the one guarded module
13
+ * does (`@vibe-agent-toolkit/utils` `followedStatSync`, `followedKindSync`,
14
+ * `realpathNativeSync`, `pathPresent(path, { follow: within }, …)`). Three rounds of
15
+ * hand sweeps for "the sites that resolve links" each missed sites; this rule is
16
+ * the sweep.
17
+ *
18
+ * `symlink` and `symlinkSync` are flagged unless CALLED with a third argument (the
19
+ * link's type): with none, Node on Windows `stat`s the target to pick `file` or `dir`,
20
+ * so creating a link is a follow of its unread text. The type is read syntactically —
21
+ * a callback passed by name in the third place reads as a type.
22
+ *
23
+ * `lstat`, `fstat`, a file handle's `stat()` and `readlink` follow nothing and are
24
+ * not flagged. Opening or reading a file through a link is outside this rule.
25
+ *
26
+ * ## Scope
27
+ *
28
+ * Product source: a file under `packages/<pkg>/src/` that is not itself a test
29
+ * file — the layout is this repo's, which is why the rule is not in
30
+ * `configs.recommended`.
31
+ *
32
+ * Option `allowFiles: string[]` — repo-relative paths: the guarded module itself,
33
+ * and files whose every such call acts on the operator's own path, each with its
34
+ * reason beside it in the config. The repo's config derives the list from
35
+ * `LINK_FOLLOWING_FS_CALLS`, which pins how many calls each listed file holds;
36
+ * `dev-tools/test/eslint-allowlist-ratchets.test.ts` asserts that count exactly —
37
+ * one more call in a listed file is red, and so is one fewer.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { productSourceFsBan } = require('./fs-binding-scan.cjs');
43
+
44
+ module.exports = productSourceFsBan({
45
+ docs: {
46
+ description:
47
+ 'Disallow fs calls that follow a symbolic link (stat, existsSync, realpath) in product source outside the ' +
48
+ 'guarded module — the kernel would follow a link no walk has read',
49
+ bans: '`stat`/`statSync`/`existsSync`/`realpath`/`realpathSync`, and `symlink`/`symlinkSync` called with no type, from `node:fs` or `node:fs/promises` under `packages/*/src/`',
50
+ useInstead: '`followedStatSync`/`followedKindSync`/`realpathNativeSync(path, within)` or `pathPresent(path, { follow: within }, …)`',
51
+ },
52
+ messageId: 'follows',
53
+ message:
54
+ '`{{name}}` follows a symbolic link with no walk of its chain first: on Windows a link to \\\\host\\share ' +
55
+ 'sends the operator\'s credentials to that host. Use followedStatSync / followedKindSync / ' +
56
+ 'realpathNativeSync(path, within) or pathPresent(path, { follow: within }, …) from @vibe-agent-toolkit/utils.',
57
+ modules: new Set(['node:fs', 'fs', 'node:fs/promises', 'fs/promises']),
58
+ banned: new Set(['stat', 'statSync', 'existsSync', 'realpath', 'realpathSync']),
59
+ untyped: {
60
+ names: new Set(['symlink', 'symlinkSync']),
61
+ messageId: 'untypedLink',
62
+ message:
63
+ '`{{name}}` with no type has Node on Windows `stat` the target to pick `file` or `dir` — the kernel ' +
64
+ 'follows a link text no walk has read, and a text naming a network share sends the operator\'s ' +
65
+ 'credentials to the share\'s host. Pass the type, decided from what VAT already read — for a link being ' +
66
+ 'copied, from its chain read by text (`copyTree` asks `linkCopyType` in utils `link-following.ts`).',
67
+ },
68
+ });
@@ -20,17 +20,68 @@
20
20
  * segment boundary via `exempt-path-matcher.cjs`; give the full repo-relative
21
21
  * path, not a bare basename.
22
22
  *
23
+ * ## Option `derived` — the code is DERIVED from the document, not chosen
24
+ *
25
+ * Naming a member is not enough. `process.exit(refused ? ExitCode.FINDINGS :
26
+ * ExitCode.OK)` names members and still re-decides, beside the document, what
27
+ * the document means — and one outcome shipped with different codes in
28
+ * different verbs that way (a budget kill 1-or-2 by timing; an unreadable root
29
+ * 2 in one verb and 1 in another). Under `derived.paths`:
30
+ *
31
+ * - `ExitCode.FINDINGS` may not be NAMED at all. "What it examined failed its
32
+ * gate" is a claim about a published document, so it comes from
33
+ * `exitCodeForReport(document)` (`@vibe-agent-toolkit/schema`) and nowhere else.
34
+ * - `process.exit(x)` / `process.exitCode = x` takes `ExitCode.OK`,
35
+ * `ExitCode.ERROR`, or a call to one of `derived.calls` (the derivations:
36
+ * `exitCodeForReport`, `exitCodeOfChild`, …). A ternary, a variable or a
37
+ * forwarded field is a code decided somewhere this rule cannot see.
38
+ * - No file under `derived.paths` is exempt: the migration ratchet that once
39
+ * listed the undelivered verbs reached zero and went, and the option with it.
40
+ *
41
+ * ⚠️ What this cannot see: a document verb ending on a bare `ExitCode.OK` over a
42
+ * document that says `error`. That is a runtime fact, pinned by the exit-code
43
+ * matrix system test, not a syntactic one.
44
+ *
23
45
  * @example
24
46
  * // BAD — 1 means what, exactly?
25
47
  * process.exit(1);
26
48
  *
27
- * // GOOD — the meaning is the code
28
- * process.exit(ExitCode.FINDINGS);
49
+ * // BAD under `derived` — the call site decides what the document means
50
+ * process.exit(errors > 0 ? ExitCode.FINDINGS : ExitCode.OK);
51
+ *
52
+ * // GOOD — derived from what was published
53
+ * process.exit(exitCodeForReport(report));
29
54
  */
30
55
 
31
56
  'use strict';
32
57
 
33
- const { createExemptPathMatcher } = require('./exempt-path-matcher.cjs');
58
+ const { createExemptDirectoryMatcher, createExemptPathMatcher, isTestFile } = require('./exempt-path-matcher.cjs');
59
+
60
+ /** The members a call site may name outright: neither is a claim about a document. */
61
+ const UNDERIVED_MEMBERS = new Set(['OK', 'ERROR']);
62
+
63
+ /** Whether `node` is `ExitCode.<name>` (non-computed). */
64
+ function isExitCodeMember(node, name) {
65
+ return (
66
+ node.type === 'MemberExpression' &&
67
+ !node.computed &&
68
+ node.object.type === 'Identifier' &&
69
+ node.object.name === 'ExitCode' &&
70
+ node.property.type === 'Identifier' &&
71
+ (name === undefined ? UNDERIVED_MEMBERS.has(node.property.name) : node.property.name === name)
72
+ );
73
+ }
74
+
75
+ /** The called function's own name — `f(…)` or `x.f(…)` — or null. */
76
+ function calleeName(node) {
77
+ if (node.type !== 'CallExpression') return null;
78
+ const { callee } = node;
79
+ if (callee.type === 'Identifier') return callee.name;
80
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
81
+ return callee.property.name;
82
+ }
83
+ return null;
84
+ }
34
85
 
35
86
  /** Whether `node` is the member expression `process.<name>` (non-computed). */
36
87
  function isProcessMember(node, name) {
@@ -84,6 +135,15 @@ module.exports = {
84
135
  type: 'object',
85
136
  properties: {
86
137
  allow: { type: 'array', items: { type: 'string' }, uniqueItems: true },
138
+ derived: {
139
+ type: 'object',
140
+ properties: {
141
+ paths: { type: 'array', items: { type: 'string' }, uniqueItems: true },
142
+ calls: { type: 'array', items: { type: 'string' }, uniqueItems: true },
143
+ },
144
+ required: ['paths', 'calls'],
145
+ additionalProperties: false,
146
+ },
87
147
  },
88
148
  additionalProperties: false,
89
149
  },
@@ -96,34 +156,62 @@ module.exports = {
96
156
  literalExitCode:
97
157
  'process.exitCode = {{literal}} re-decides the exit contract at this site. Assign an ' +
98
158
  'ExitCode member from @vibe-agent-toolkit/schema instead.',
159
+ findingsNotDerived:
160
+ 'ExitCode.FINDINGS is a claim about a published document ("what it examined failed its ' +
161
+ 'gate"). Derive it: process.exit(exitCodeForReport(document)) — never name it at a call site.',
162
+ exitNotDerived:
163
+ 'This exit code is decided here, beside the document, where it can disagree with it. Exit ' +
164
+ 'with ExitCode.OK, ExitCode.ERROR, or a derivation: {{calls}}.',
99
165
  },
100
166
  },
101
167
 
102
168
  create(context) {
103
169
  const allow = context.options?.[0]?.allow ?? [];
170
+ const derived = context.options?.[0]?.derived;
104
171
  const filename = context.filename ?? context.getFilename();
105
172
  if (createExemptPathMatcher(allow)(filename)) {
106
173
  return {};
107
174
  }
108
175
 
176
+ const inDerived = derived !== undefined
177
+ && !isTestFile(filename)
178
+ && createExemptDirectoryMatcher(derived.paths)(filename);
179
+ const derivations = new Set(derived?.calls ?? []);
180
+
181
+ function reportDerived(node, messageId) {
182
+ context.report({ node, messageId, data: { calls: [...derivations].join(', ') } });
183
+ }
184
+
185
+ /** @returns {boolean} Whether a literal was reported (so nothing else is). */
109
186
  function reportLiteral(node, expr, messageId) {
110
187
  const leaf = numericLiteralLeaf(expr);
111
- if (leaf !== null) {
112
- context.report({ node, messageId, data: { literal: String(leaf.raw ?? leaf.value) } });
113
- }
188
+ if (leaf === null) return false;
189
+ context.report({ node, messageId, data: { literal: String(leaf.raw ?? leaf.value) } });
190
+ return true;
191
+ }
192
+
193
+ function checkExitValue(node, expr, literalMessageId) {
194
+ if (reportLiteral(node, expr, literalMessageId) || !inDerived) return;
195
+ if (isExitCodeMember(expr) || derivations.has(calleeName(expr))) return;
196
+ // `ExitCode.FINDINGS` itself is reported by the member visitor below.
197
+ if (isExitCodeMember(expr, 'FINDINGS')) return;
198
+ reportDerived(node, 'exitNotDerived');
114
199
  }
115
200
 
116
201
  return {
117
202
  CallExpression(node) {
118
203
  if (isProcessMember(node.callee, 'exit') && node.arguments.length > 0) {
119
- reportLiteral(node, node.arguments[0], 'literalExit');
204
+ checkExitValue(node, node.arguments[0], 'literalExit');
120
205
  }
121
206
  },
122
207
  AssignmentExpression(node) {
123
208
  if (isProcessMember(node.left, 'exitCode')) {
124
- reportLiteral(node, node.right, 'literalExitCode');
209
+ checkExitValue(node, node.right, 'literalExitCode');
125
210
  }
126
211
  },
212
+ MemberExpression(node) {
213
+ if (inDerived && isExitCodeMember(node, 'FINDINGS')) reportDerived(node, 'findingsNotDerived');
214
+ },
127
215
  };
128
216
  },
129
217
  };