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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +43 -9
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/crawl-timing.d.ts +3 -3
  5. package/dist/crawl-timing.js +3 -3
  6. package/dist/dirent-kind.d.ts +83 -0
  7. package/dist/dirent-kind.d.ts.map +1 -0
  8. package/dist/dirent-kind.js +129 -0
  9. package/dist/dirent-kind.js.map +1 -0
  10. package/dist/entrypoint.d.ts +54 -0
  11. package/dist/entrypoint.d.ts.map +1 -0
  12. package/dist/entrypoint.js +71 -0
  13. package/dist/entrypoint.js.map +1 -0
  14. package/dist/env-flag.d.ts +46 -0
  15. package/dist/env-flag.d.ts.map +1 -0
  16. package/dist/env-flag.js +57 -0
  17. package/dist/env-flag.js.map +1 -0
  18. package/dist/errors/errno.d.ts +51 -0
  19. package/dist/errors/errno.d.ts.map +1 -0
  20. package/dist/errors/errno.js +112 -0
  21. package/dist/errors/errno.js.map +1 -0
  22. package/dist/errors/vat-error.d.ts +60 -0
  23. package/dist/errors/vat-error.d.ts.map +1 -0
  24. package/dist/errors/vat-error.js +86 -0
  25. package/dist/errors/vat-error.js.map +1 -0
  26. package/dist/file-crawler.d.ts +22 -0
  27. package/dist/file-crawler.d.ts.map +1 -1
  28. package/dist/file-crawler.js +83 -22
  29. package/dist/file-crawler.js.map +1 -1
  30. package/dist/fs/file-hash.d.ts.map +1 -1
  31. package/dist/fs/file-hash.js +0 -1
  32. package/dist/fs/file-hash.js.map +1 -1
  33. package/dist/fs-utils.d.ts +422 -217
  34. package/dist/fs-utils.d.ts.map +1 -1
  35. package/dist/fs-utils.js +535 -283
  36. package/dist/fs-utils.js.map +1 -1
  37. package/dist/fs.d.ts +3 -2
  38. package/dist/fs.d.ts.map +1 -1
  39. package/dist/fs.js +5 -7
  40. package/dist/fs.js.map +1 -1
  41. package/dist/git-run.d.ts +1 -1
  42. package/dist/git-run.js +1 -1
  43. package/dist/git-tracker.d.ts +41 -2
  44. package/dist/git-tracker.d.ts.map +1 -1
  45. package/dist/git-tracker.js +84 -19
  46. package/dist/git-tracker.js.map +1 -1
  47. package/dist/git-utils.d.ts +47 -2
  48. package/dist/git-utils.d.ts.map +1 -1
  49. package/dist/git-utils.js +123 -20
  50. package/dist/git-utils.js.map +1 -1
  51. package/dist/git.d.ts +1 -0
  52. package/dist/git.d.ts.map +1 -1
  53. package/dist/git.js.map +1 -1
  54. package/dist/gitignore-checker.d.ts.map +1 -1
  55. package/dist/gitignore-checker.js +9 -3
  56. package/dist/gitignore-checker.js.map +1 -1
  57. package/dist/index.d.ts +10 -4
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +48 -22
  60. package/dist/index.js.map +1 -1
  61. package/dist/listing-refusal.d.ts +112 -0
  62. package/dist/listing-refusal.d.ts.map +1 -0
  63. package/dist/listing-refusal.js +105 -0
  64. package/dist/listing-refusal.js.map +1 -0
  65. package/dist/path-containment.d.ts +64 -0
  66. package/dist/path-containment.d.ts.map +1 -0
  67. package/dist/path-containment.js +111 -0
  68. package/dist/path-containment.js.map +1 -0
  69. package/dist/path-core.d.ts +77 -3
  70. package/dist/path-core.d.ts.map +1 -1
  71. package/dist/path-core.js +96 -6
  72. package/dist/path-core.js.map +1 -1
  73. package/dist/path-utils.d.ts.map +1 -1
  74. package/dist/path-utils.js +42 -47
  75. package/dist/path-utils.js.map +1 -1
  76. package/dist/path.d.ts +1 -1
  77. package/dist/path.d.ts.map +1 -1
  78. package/dist/path.js +1 -1
  79. package/dist/path.js.map +1 -1
  80. package/dist/process.d.ts +7 -0
  81. package/dist/process.d.ts.map +1 -1
  82. package/dist/process.js +7 -0
  83. package/dist/process.js.map +1 -1
  84. package/dist/project-utils.d.ts.map +1 -1
  85. package/dist/project-utils.js +9 -6
  86. package/dist/project-utils.js.map +1 -1
  87. package/dist/safe-exec.d.ts +2 -1
  88. package/dist/safe-exec.d.ts.map +1 -1
  89. package/dist/safe-exec.js +26 -8
  90. package/dist/safe-exec.js.map +1 -1
  91. package/dist/skill-targets.d.ts +2 -2
  92. package/dist/skill-targets.js +2 -2
  93. package/dist/skill-test/auth-resolver.d.ts +10 -2
  94. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  95. package/dist/skill-test/auth-resolver.js +26 -11
  96. package/dist/skill-test/auth-resolver.js.map +1 -1
  97. package/dist/skill-test/spawn-claude.d.ts +2 -1
  98. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  99. package/dist/skill-test/spawn-claude.js +20 -3
  100. package/dist/skill-test/spawn-claude.js.map +1 -1
  101. package/dist/skill-test/transcript.d.ts.map +1 -1
  102. package/dist/skill-test/transcript.js +18 -17
  103. package/dist/skill-test/transcript.js.map +1 -1
  104. package/dist/stdio-blocking.js +13 -14
  105. package/dist/stdio-blocking.js.map +1 -1
  106. package/dist/test-helpers.d.ts +56 -193
  107. package/dist/test-helpers.d.ts.map +1 -1
  108. package/dist/test-helpers.js +115 -284
  109. package/dist/test-helpers.js.map +1 -1
  110. package/dist/testing/executables.d.ts +32 -0
  111. package/dist/testing/executables.d.ts.map +1 -0
  112. package/dist/testing/executables.js +70 -0
  113. package/dist/testing/executables.js.map +1 -0
  114. package/dist/testing/hostile-tree.d.ts +79 -0
  115. package/dist/testing/hostile-tree.d.ts.map +1 -0
  116. package/dist/testing/hostile-tree.js +164 -0
  117. package/dist/testing/hostile-tree.js.map +1 -0
  118. package/dist/testing/platform-gates.d.ts +31 -0
  119. package/dist/testing/platform-gates.d.ts.map +1 -0
  120. package/dist/testing/platform-gates.js +31 -0
  121. package/dist/testing/platform-gates.js.map +1 -0
  122. package/dist/testing/temp-dir.d.ts +255 -0
  123. package/dist/testing/temp-dir.d.ts.map +1 -0
  124. package/dist/testing/temp-dir.js +363 -0
  125. package/dist/testing/temp-dir.js.map +1 -0
  126. package/dist/testing.d.ts +21 -0
  127. package/dist/testing.d.ts.map +1 -1
  128. package/dist/testing.js +27 -1
  129. package/dist/testing.js.map +1 -1
  130. package/dist/text-content.d.ts.map +1 -1
  131. package/dist/text-content.js +7 -1
  132. package/dist/text-content.js.map +1 -1
  133. package/dist/text-file.d.ts.map +1 -1
  134. package/dist/text-file.js +0 -2
  135. package/dist/text-file.js.map +1 -1
  136. package/dist/timing-dump.d.ts +1 -1
  137. package/dist/timing-dump.d.ts.map +1 -1
  138. package/dist/timing-dump.js +1 -2
  139. package/dist/timing-dump.js.map +1 -1
  140. package/dist/windows-shell.d.ts +0 -1
  141. package/dist/windows-shell.d.ts.map +1 -1
  142. package/dist/windows-shell.js +0 -1
  143. package/dist/windows-shell.js.map +1 -1
  144. package/dist/zod-introspection.js +2 -2
  145. package/dist/zod-introspection.js.map +1 -1
  146. package/eslint/README.md +132 -60
  147. package/eslint/index.cjs +111 -147
  148. package/eslint/index.d.cts +14 -14
  149. package/eslint/rules/callee-name.cjs +23 -0
  150. package/eslint/rules/commands-import-boundary.cjs +148 -0
  151. package/eslint/rules/dead-import.cjs +1 -1
  152. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  153. package/eslint/rules/eslint-rule-factory.cjs +5 -2
  154. package/eslint/rules/exempt-path-matcher.cjs +1 -1
  155. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  156. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  157. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  158. package/eslint/rules/no-bare-symlink-in-tests.cjs +16 -3
  159. package/eslint/rules/no-blind-catch.cjs +171 -0
  160. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  161. package/eslint/rules/no-decaying-referent.cjs +216 -0
  162. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  163. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  164. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  165. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  166. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  167. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  168. package/eslint/rules/no-hardcoded-path-split.cjs +7 -2
  169. package/eslint/rules/no-io-in-unit-tier.cjs +128 -0
  170. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  171. package/eslint/rules/no-manual-path-normalize.cjs +7 -2
  172. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  173. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  174. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  175. package/eslint/rules/no-path-startswith.cjs +7 -2
  176. package/eslint/rules/no-process-exit-in-phase.cjs +12 -2
  177. package/eslint/rules/no-raw-node-path.cjs +563 -0
  178. package/eslint/rules/no-raw-text-decode.cjs +10 -1
  179. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  180. package/eslint/rules/no-self-package-import.cjs +13 -2
  181. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  182. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  183. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  184. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  185. package/eslint/rules/no-version-literal.cjs +151 -0
  186. package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
  187. package/eslint/rules/require-justified-skip.cjs +8 -2
  188. package/eslint/rules/safe-import.cjs +1 -1
  189. package/package.json +5 -10
  190. package/eslint/rules/no-path-join.cjs +0 -14
  191. package/eslint/rules/no-path-relative.cjs +0 -14
  192. package/eslint/rules/no-path-resolve.cjs +0 -14
  193. package/eslint/rules/path-function-rule-factory.cjs +0 -496
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ESLint rule: no-fragile-entrypoint-guard
3
+ *
4
+ * Ban the two "am I the script Node was asked to run?" idioms that answer
5
+ * **false for the script they are guarding**, so the process exits 0 having done
6
+ * nothing at all.
7
+ *
8
+ * @example
9
+ * // ❌ BAD — undefined before Node 24.2 / 22.18
10
+ * if (import.meta.main) { await main(); }
11
+ *
12
+ * // ❌ BAD — raw string compare, no realpath: false through any `.bin` symlink
13
+ * if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { … }
14
+ *
15
+ * // ❌ BAD — the same compare done in path space instead of URL space
16
+ * if (fileURLToPath(import.meta.url) === process.argv[1]) { … }
17
+ *
18
+ * // ✅ GOOD
19
+ * import { isEntrypoint } from '@vibe-agent-toolkit/utils/process';
20
+ * if (isEntrypoint(import.meta.url)) { await main(); }
21
+ *
22
+ * ## Why a lint rule and not a comment
23
+ *
24
+ * Both defects have already shipped in this repo, and both were invisible to
25
+ * every gate:
26
+ *
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`:
29
+ *
30
+ * ```
31
+ * $ node-v22.13.0 --input-type=module -e "console.log(import.meta.main)" -> undefined
32
+ * $ node-v24.13.1 --input-type=module -e "console.log(import.meta.main)" -> true
33
+ * ```
34
+ *
35
+ * So a repository-structure gate guarded this way printed nothing and exited 0
36
+ * on the very Node its own CI job installs. A contributor sitting exactly on
37
+ * the supported floor got a green pre-commit gate that had run no rule.
38
+ *
39
+ * - `import.meta.url === pathToFileURL(process.argv[1]).href` compares two
40
+ * strings with no realpath pass. Invoked through a `node_modules/.bin` shim —
41
+ * i.e. the normal way a package's own bin is run — `process.argv[1]` is the
42
+ * SYMLINK and `import.meta.url` is the resolved target, the strings differ,
43
+ * and the guard is false. Measured false on Node 22.14.0 and 24.13.1 alike,
44
+ * where `isEntrypoint()` is true.
45
+ *
46
+ * ⚠️ That defect is about the MISSING REALPATH, not about URLs, so it has a
47
+ * second spelling that is just as common and just as wrong:
48
+ * `fileURLToPath(import.meta.url) === process.argv[1]` converts the module URL
49
+ * to a path instead of converting the invoked path to a URL, and then compares
50
+ * the same two unresolved strings. A matcher that only knew the URL-space form
51
+ * would be a mechanism with a hole in the middle of its own premise, which is
52
+ * the thing this rule exists to refuse. Both directions of both spellings are
53
+ * flagged, under one message, because they are one defect.
54
+ *
55
+ * The first of those was fixed once already and the fix was pinned by nothing:
56
+ * reverting all three call sites to `if (import.meta.main)` left the entire test
57
+ * suite green, because the only thing standing against it was three prose
58
+ * comments addressed to a human. A banner is not a mechanism. This rule is.
59
+ *
60
+ * ## What it does NOT flag
61
+ *
62
+ * `import.meta.url`, `import.meta.dirname` and `import.meta.filename` are all
63
+ * fine and all common — only `.main` is the unavailable one. And
64
+ * `pathToFileURL(x).href` is only a finding when it is being compared to
65
+ * `import.meta.url`; on its own it is just a URL.
66
+ *
67
+ * ## No `exemptFiles`
68
+ *
69
+ * Unlike the wrapper rules in this pack, there is no implementation file that
70
+ * has to call the banned thing: `isEntrypoint()` is written in terms of
71
+ * `process.argv[1]` and a realpath comparison, and touches neither idiom. An
72
+ * exemption option here would only ever be used to opt a file out of a fix.
73
+ */
74
+
75
+ 'use strict';
76
+
77
+ /** `import.meta`, as the parser sees it. */
78
+ function isImportMeta(node) {
79
+ return (
80
+ node?.type === 'MetaProperty' && node.meta?.name === 'import' && node.property?.name === 'meta'
81
+ );
82
+ }
83
+
84
+ /**
85
+ * `import.meta.<name>` — a static, non-computed member of `import.meta`.
86
+ *
87
+ * @param {object} node - Any node.
88
+ * @param {string} name - The property to match, e.g. `main`.
89
+ * @returns {boolean}
90
+ */
91
+ function isImportMetaMember(node, name) {
92
+ return (
93
+ node?.type === 'MemberExpression' &&
94
+ node.computed === false &&
95
+ isImportMeta(node.object) &&
96
+ node.property?.type === 'Identifier' &&
97
+ node.property.name === name
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The called function's NAME, whether called bare or off a namespace.
103
+ *
104
+ * `pathToFileURL(p)` and `url.pathToFileURL(p)` are the same call, and a matcher
105
+ * that only understood the bare form would be blind to every file that imports
106
+ * `node:url` as a namespace.
107
+ *
108
+ * @param {object} callee - The `callee` of a CallExpression.
109
+ * @returns {string | undefined}
110
+ */
111
+ function calleeName(callee) {
112
+ if (callee === null || callee === undefined) return undefined;
113
+ if (callee.type === 'Identifier') return callee.name;
114
+ if (callee.type === 'MemberExpression' && callee.computed === false) {
115
+ return callee.property?.type === 'Identifier' ? callee.property.name : undefined;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /** `pathToFileURL(…).href`, in either import style. */
121
+ function isPathToFileUrlHref(node) {
122
+ return (
123
+ node?.type === 'MemberExpression' &&
124
+ node.computed === false &&
125
+ node.property?.type === 'Identifier' &&
126
+ node.property.name === 'href' &&
127
+ node.object?.type === 'CallExpression' &&
128
+ calleeName(node.object.callee) === 'pathToFileURL'
129
+ );
130
+ }
131
+
132
+ /** `fileURLToPath(import.meta.url)`, in either import style. */
133
+ function isFileUrlToPathOfImportMetaUrl(node) {
134
+ return (
135
+ node?.type === 'CallExpression' &&
136
+ calleeName(node.callee) === 'fileURLToPath' &&
137
+ isImportMetaMember(node.arguments?.[0], 'url')
138
+ );
139
+ }
140
+
141
+ /**
142
+ * `process.argv[1]` — and the `const { argv } = process` spelling of it.
143
+ *
144
+ * The index is pinned to `1` on purpose: `argv[2]` and up are ordinary CLI
145
+ * arguments, and comparing one of those to anything is not this defect.
146
+ */
147
+ function isArgvEntry(node) {
148
+ if (node === null || node === undefined) return false;
149
+ if (node.type !== 'MemberExpression' || node.computed !== true) return false;
150
+ if (node.property?.type !== 'Literal' || node.property.value !== 1) return false;
151
+
152
+ const target = node.object;
153
+ if (target?.type === 'Identifier') return target.name === 'argv';
154
+ return (
155
+ target?.type === 'MemberExpression' &&
156
+ target.computed === false &&
157
+ target.property?.type === 'Identifier' &&
158
+ target.property.name === 'argv'
159
+ );
160
+ }
161
+
162
+ /** Where THIS module lives, in whichever space the comparison is written in. */
163
+ function isModuleLocation(node) {
164
+ return isImportMetaMember(node, 'url') || isFileUrlToPathOfImportMetaUrl(node);
165
+ }
166
+
167
+ /** The script Node was ASKED to run, in whichever space the comparison uses. */
168
+ function isInvokedScript(node) {
169
+ return isArgvEntry(node) || isPathToFileUrlHref(node);
170
+ }
171
+
172
+ /** Identity comparisons; `==`/`!=` on these operands never occurs and is not the idiom. */
173
+ const IDENTITY_OPERATORS = new Set(['===', '!==']);
174
+
175
+ module.exports = {
176
+ meta: {
177
+ type: 'problem',
178
+ docs: {
179
+ description:
180
+ 'Ban entrypoint guards that silently answer false — `import.meta.main` (undefined before Node 24.2/22.18) and any raw compare of where the module lives to `process.argv[1]`, in URL space or path space (false through any symlink). Use `isEntrypoint()`.',
181
+ category: 'Entrypoint guards',
182
+ bans: '`import.meta.main`; `import.meta.url === pathToFileURL(process.argv[1]).href`; `fileURLToPath(import.meta.url) === process.argv[1]`',
183
+ useInstead: '`isEntrypoint(import.meta.url)`',
184
+ subpath: '/process',
185
+ // Not in `recommended`: ONE of its two halves is a claim about the CONSUMER's
186
+ // Node floor rather than a portable fact. `import.meta.main` shipped in Node
187
+ // 24.2 / 22.18; an adopter whose floor is at or above that writes it
188
+ // 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
+ // is real for some adopters and absent for others, and only they know which.
191
+ // The other half (`rawEntrypointCompare`) has no such dependency — a raw
192
+ // string compare misses a symlinked entry on every Node there has ever been
193
+ // — but the two share one rule id. VAT enables the whole rule explicitly.
194
+ recommended: false,
195
+ recommendedSeverity: 'error',
196
+ },
197
+ messages: {
198
+ importMetaMain:
199
+ '`import.meta.main` is undefined before Node 24.2 / 22.18, so this guard is FALSE on older supported Node and the script exits 0 having done nothing. Use `isEntrypoint(import.meta.url)` from `@vibe-agent-toolkit/utils/process`.',
200
+ rawEntrypointCompare:
201
+ 'Comparing where this module lives to `process.argv[1]` (as `import.meta.url === pathToFileURL(argv[1]).href`, or as `fileURLToPath(import.meta.url) === argv[1]`) is a raw string compare with no realpath pass, so it is FALSE whenever the script is reached through a symlink (any `node_modules/.bin` shim) and the script exits 0 having done nothing. Use `isEntrypoint(import.meta.url)` from `@vibe-agent-toolkit/utils/process`.',
202
+ },
203
+ schema: [],
204
+ },
205
+
206
+ create(context) {
207
+ return {
208
+ MemberExpression(node) {
209
+ if (isImportMetaMember(node, 'main')) {
210
+ context.report({ node, messageId: 'importMetaMain' });
211
+ }
212
+ },
213
+
214
+ BinaryExpression(node) {
215
+ if (!IDENTITY_OPERATORS.has(node.operator)) return;
216
+
217
+ // Either operand order: a matcher keyed on side would be half blind.
218
+ const compares =
219
+ (isModuleLocation(node.left) && isInvokedScript(node.right)) ||
220
+ (isModuleLocation(node.right) && isInvokedScript(node.left));
221
+
222
+ if (compares) {
223
+ context.report({ node, messageId: 'rawEntrypointCompare' });
224
+ }
225
+ },
226
+ };
227
+ },
228
+ };
@@ -20,4 +20,12 @@ module.exports = factory({
20
20
  message: 'Use mkdirSyncReal() from {{safeModule}} instead of fs.mkdirSync() for Windows path normalization',
21
21
  // No baked-in exemption: the file that implements mkdirSyncReal() is
22
22
  // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ docs: {
24
+ category: 'Filesystem and process',
25
+ bans: '`fs.mkdirSync()`',
26
+ useInstead: '`mkdirSyncReal()`',
27
+ subpath: '/fs',
28
+ recommended: true,
29
+ recommendedSeverity: 'error',
30
+ },
23
31
  });
@@ -33,4 +33,11 @@ module.exports = factory({
33
33
  'Use cpSync() from {{safeModule}} instead of cp() from node:fs/promises. ' +
34
34
  'Node 22 async cp({ recursive: true }) silently drops files in nested directories. ' +
35
35
  'cpSync() works correctly across all Node versions.',
36
+ docs: {
37
+ category: 'Filesystem and process',
38
+ bans: '`cp()` from `node:fs/promises` (drops nested files on Node 22)',
39
+ useInstead: '`cpSync()` from `node:fs`',
40
+ recommended: true,
41
+ recommendedSeverity: 'error',
42
+ },
36
43
  });
@@ -20,4 +20,12 @@ module.exports = factory({
20
20
  message: 'Use normalizePath() from {{safeModule}} instead of fs.realpathSync() for consistent Windows 8.3 path resolution',
21
21
  // No baked-in exemption: the file that implements normalizePath() is
22
22
  // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
+ docs: {
24
+ category: 'Filesystem and process',
25
+ bans: '`fs.realpathSync()`',
26
+ useInstead: '`normalizePath()`',
27
+ subpath: '/fs',
28
+ recommended: true,
29
+ recommendedSeverity: 'error',
30
+ },
23
31
  });
@@ -36,9 +36,14 @@ module.exports = {
36
36
  meta: {
37
37
  type: 'problem',
38
38
  docs: {
39
- description: 'Disallow splitting strings by hardcoded path separators',
40
- category: 'Cross-platform compatibility',
39
+ description:
40
+ 'Disallow splitting strings by hardcoded path separators',
41
+ category: 'Path handling',
42
+ bans: "`split('/')` / `split('\\\\')` on a path",
43
+ useInstead: '`path.basename()`, or `toForwardSlash()` first',
44
+ subpath: '/path',
41
45
  recommended: true,
46
+ recommendedSeverity: 'error',
42
47
  },
43
48
  messages: {
44
49
  noHardcodedSplit:
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ESLint rule: no-io-in-unit-tier
3
+ *
4
+ * In a UNIT-tier test file, flags an import of `node:child_process` /
5
+ * `child_process` and any call to `mkdtemp`, `mkdtempSync`, `spawn`,
6
+ * `spawnSync`, `execSync` or `execFileSync` — bare or through a namespace.
7
+ *
8
+ * The tiers are a promise about cost and about what is being tested: a unit
9
+ * test exercises logic and finishes in milliseconds; an integration test
10
+ * wires real components; a system test runs the real binary. A `*.test.ts`
11
+ * that spawns `git` or mints a temp directory has silently moved tiers
12
+ * without moving files — it runs under the unit budget, it is coverage-
13
+ * instrumented as if it were pure, and it is the first thing to flake on a
14
+ * loaded CI runner. The audit measured the class at 93 unit files touching
15
+ * the real filesystem and 5 spawning real processes. Moving a file to the
16
+ * tier it belongs to is a rename; this rule makes the rename happen at the
17
+ * desk rather than in a 53-second package run.
18
+ *
19
+ * ## The tier boundary, as this rule reads it
20
+ *
21
+ * A file is unit-tier when it is a test file (`*.test.ts` and friends) under
22
+ * `packages/<pkg>/test/` at any depth, EXCEPT when it sits under an
23
+ * `integration/` or `system/` directory or carries the `.integration.test.`
24
+ * / `.system.test.` suffix. Helpers (`test-helpers.ts`) are not test files
25
+ * and are not checked here; a helper that spawns is caught at the unit file
26
+ * that calls it only if the call is visible there, which is a known floor.
27
+ *
28
+ * Option `allowFiles: string[]` — repo-relative paths of today's offenders,
29
+ * the ratchet. Name files, never directories.
30
+ *
31
+ * @example
32
+ * // BAD in packages/x/test/thing.test.ts — a system test wearing a unit name
33
+ * const out = spawnSync('node', [bin, '--json']);
34
+ *
35
+ * // GOOD — same code, in packages/x/test/system/thing.system.test.ts
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const { calleeName } = require('./callee-name.cjs');
41
+ const { createExemptPathMatcher, isTestFile } = require('./exempt-path-matcher.cjs');
42
+
43
+ const CHILD_PROCESS_MODULES = new Set(['node:child_process', 'child_process']);
44
+ const IO_CALLS = new Set(['mkdtemp', 'mkdtempSync', 'spawn', 'spawnSync', 'execSync', 'execFileSync']);
45
+
46
+ /** `packages/<pkg>/test/` anywhere in the path, with either separator already normalised. */
47
+ const PACKAGE_TEST_DIR = /(?:^|\/)packages\/[^/]+\/test\//u;
48
+
49
+ /** The integration and system tiers, by directory or by suffix. */
50
+ const OTHER_TIER = /\/(?:integration|system)\/|\.(?:integration|system)\.test\./u;
51
+
52
+ /** Whether `filename` is a unit-tier test file under a package's `test/`. */
53
+ function isUnitTierFile(filename) {
54
+ if (!filename) {
55
+ return false;
56
+ }
57
+ const normalized = String(filename).replaceAll('\\', '/');
58
+ return isTestFile(normalized) && PACKAGE_TEST_DIR.test(normalized) && !OTHER_TIER.test(normalized);
59
+ }
60
+
61
+ /** The banned callee name of a call, bare (`spawn(…)`) or namespaced (`cp.spawn(…)`), or null. */
62
+ function bannedCallName(call) {
63
+ const name = calleeName(call);
64
+ return name !== null && IO_CALLS.has(name) ? name : null;
65
+ }
66
+
67
+ module.exports = {
68
+ meta: {
69
+ type: 'problem',
70
+ docs: {
71
+ description:
72
+ 'Disallow child_process imports and mkdtemp/spawn/exec calls in unit-tier test files — ' +
73
+ 'a test that spawns or writes to disk belongs in the integration or system tier',
74
+ recommended: false,
75
+ recommendedSeverity: 'warn',
76
+ },
77
+ schema: [
78
+ {
79
+ type: 'object',
80
+ properties: {
81
+ allowFiles: { type: 'array', items: { type: 'string' }, uniqueItems: true },
82
+ },
83
+ additionalProperties: false,
84
+ },
85
+ ],
86
+ messages: {
87
+ childProcessImport:
88
+ "A unit-tier test imports '{{source}}': spawning a process is integration or system work. " +
89
+ 'Move the file to test/integration/ (*.integration.test.ts) or test/system/ ' +
90
+ '(*.system.test.ts), or mock the module.',
91
+ ioCall:
92
+ 'A unit-tier test calls {{name}}(): real processes and temp directories belong to the ' +
93
+ 'integration or system tier. Move the file to that tier, or test the logic without the I/O.',
94
+ },
95
+ },
96
+
97
+ create(context) {
98
+ const filename = context.filename ?? context.getFilename();
99
+ const isAllowed = createExemptPathMatcher(context.options?.[0]?.allowFiles ?? []);
100
+ if (!isUnitTierFile(filename) || isAllowed(filename)) {
101
+ return {};
102
+ }
103
+
104
+ function checkModule(node, sourceNode) {
105
+ const source = sourceNode?.type === 'Literal' ? sourceNode.value : null;
106
+ if (typeof source === 'string' && CHILD_PROCESS_MODULES.has(source)) {
107
+ context.report({ node, messageId: 'childProcessImport', data: { source } });
108
+ }
109
+ }
110
+
111
+ return {
112
+ ImportDeclaration(node) {
113
+ if (node.importKind !== 'type') {
114
+ checkModule(node, node.source);
115
+ }
116
+ },
117
+ ImportExpression(node) {
118
+ checkModule(node, node.source);
119
+ },
120
+ CallExpression(node) {
121
+ const name = bannedCallName(node);
122
+ if (name !== null) {
123
+ context.report({ node, messageId: 'ioCall', data: { name } });
124
+ }
125
+ },
126
+ };
127
+ },
128
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * ESLint rule: no-literal-process-exit
3
+ *
4
+ * Flags `process.exit(<number>)` and `process.exitCode = <number>`. A numeric
5
+ * exit code written at the call site re-decides the exit contract inline: one
6
+ * command exits 1 for "findings" and another exits 1 for "the config was
7
+ * unreadable", and nothing but a reader comparing two files can tell. The
8
+ * allowed shape names the meaning — `process.exit(ExitCode.FINDINGS)` — so the
9
+ * contract lives in one enum and every command shares it.
10
+ *
11
+ * The literal may hide inside an expression: `ok ? 0 : 1`, `status ?? 1`,
12
+ * `-1`. Those are flagged too, because each branch is still a number chosen at
13
+ * this site. Any non-literal argument — an identifier, a member, a call
14
+ * result — is accepted; the rule is a syntactic floor and does not chase what
15
+ * a variable holds.
16
+ *
17
+ * Option `allow: string[]` — repo-relative paths of the files permitted to
18
+ * exit with a literal (the one `bin.ts` last-resort fallback that runs when
19
+ * the enum module itself failed to load). Entries are matched at a path
20
+ * segment boundary via `exempt-path-matcher.cjs`; give the full repo-relative
21
+ * path, not a bare basename.
22
+ *
23
+ * @example
24
+ * // BAD — 1 means what, exactly?
25
+ * process.exit(1);
26
+ *
27
+ * // GOOD — the meaning is the code
28
+ * process.exit(ExitCode.FINDINGS);
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const { createExemptPathMatcher } = require('./exempt-path-matcher.cjs');
34
+
35
+ /** Whether `node` is the member expression `process.<name>` (non-computed). */
36
+ function isProcessMember(node, name) {
37
+ return (
38
+ node.type === 'MemberExpression' &&
39
+ !node.computed &&
40
+ node.object.type === 'Identifier' &&
41
+ node.object.name === 'process' &&
42
+ node.property.type === 'Identifier' &&
43
+ node.property.name === name
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The first numeric literal reachable through the expression's value
49
+ * branches, or null. Walks conditionals, nullish/logical fallbacks, unary
50
+ * minus, and TypeScript wrappers — every path a number can take to become the
51
+ * exit code without passing through a name.
52
+ */
53
+ function numericLiteralLeaf(expr) {
54
+ switch (expr.type) {
55
+ case 'Literal':
56
+ return typeof expr.value === 'number' ? expr : null;
57
+ case 'UnaryExpression':
58
+ return numericLiteralLeaf(expr.argument);
59
+ case 'ConditionalExpression':
60
+ return numericLiteralLeaf(expr.consequent) ?? numericLiteralLeaf(expr.alternate);
61
+ case 'LogicalExpression':
62
+ return numericLiteralLeaf(expr.left) ?? numericLiteralLeaf(expr.right);
63
+ case 'TSAsExpression':
64
+ case 'TSNonNullExpression':
65
+ case 'TSSatisfiesExpression':
66
+ return numericLiteralLeaf(expr.expression);
67
+ default:
68
+ return null;
69
+ }
70
+ }
71
+
72
+ module.exports = {
73
+ meta: {
74
+ type: 'problem',
75
+ docs: {
76
+ description:
77
+ 'Disallow process.exit(<number>) and process.exitCode = <number> — name the meaning with ' +
78
+ 'the ExitCode enum so every command shares one exit contract',
79
+ recommended: false,
80
+ recommendedSeverity: 'warn',
81
+ },
82
+ schema: [
83
+ {
84
+ type: 'object',
85
+ properties: {
86
+ allow: { type: 'array', items: { type: 'string' }, uniqueItems: true },
87
+ },
88
+ additionalProperties: false,
89
+ },
90
+ ],
91
+ messages: {
92
+ literalExit:
93
+ 'process.exit({{literal}}) re-decides the exit contract at this call site. Use the ' +
94
+ 'ExitCode enum from @vibe-agent-toolkit/schema — process.exit(ExitCode.X) — so the code ' +
95
+ 'means the same thing in every command.',
96
+ literalExitCode:
97
+ 'process.exitCode = {{literal}} re-decides the exit contract at this site. Assign an ' +
98
+ 'ExitCode member from @vibe-agent-toolkit/schema instead.',
99
+ },
100
+ },
101
+
102
+ create(context) {
103
+ const allow = context.options?.[0]?.allow ?? [];
104
+ const filename = context.filename ?? context.getFilename();
105
+ if (createExemptPathMatcher(allow)(filename)) {
106
+ return {};
107
+ }
108
+
109
+ function reportLiteral(node, expr, messageId) {
110
+ const leaf = numericLiteralLeaf(expr);
111
+ if (leaf !== null) {
112
+ context.report({ node, messageId, data: { literal: String(leaf.raw ?? leaf.value) } });
113
+ }
114
+ }
115
+
116
+ return {
117
+ CallExpression(node) {
118
+ if (isProcessMember(node.callee, 'exit') && node.arguments.length > 0) {
119
+ reportLiteral(node, node.arguments[0], 'literalExit');
120
+ }
121
+ },
122
+ AssignmentExpression(node) {
123
+ if (isProcessMember(node.left, 'exitCode')) {
124
+ reportLiteral(node, node.right, 'literalExitCode');
125
+ }
126
+ },
127
+ };
128
+ },
129
+ };
@@ -33,9 +33,14 @@ module.exports = {
33
33
  meta: {
34
34
  type: 'problem',
35
35
  docs: {
36
- description: 'Disallow manual path normalization patterns',
37
- category: 'Cross-platform compatibility',
36
+ description:
37
+ 'Disallow manual path normalization patterns',
38
+ category: 'Path handling',
39
+ bans: "hand-rolled `.replace(/\\\\/g, '/')`",
40
+ useInstead: '`toForwardSlash()`',
41
+ subpath: '/path',
38
42
  recommended: true,
43
+ recommendedSeverity: 'error',
39
44
  },
40
45
  fixable: 'code',
41
46
  messages: {
@@ -21,4 +21,12 @@ module.exports = factory({
21
21
  // No baked-in exemption: the file that implements normalizedTmpdir() is
22
22
  // repo-specific. Consumers declare it as { exemptFiles: [...] }.
23
23
  checkMemberExpression: true, // Catch os.tmpdir() pattern
24
+ docs: {
25
+ category: 'Filesystem and process',
26
+ bans: '`os.tmpdir()` (8.3 short names on Windows)',
27
+ useInstead: '`normalizedTmpdir()`',
28
+ subpath: '/fs',
29
+ recommended: true,
30
+ recommendedSeverity: 'error',
31
+ },
24
32
  });
@@ -25,9 +25,14 @@ module.exports = {
25
25
  meta: {
26
26
  type: 'problem',
27
27
  docs: {
28
- description: 'Disallow using path operations directly in string comparisons',
29
- category: 'Cross-platform compatibility',
28
+ description:
29
+ 'Disallow using path operations directly in string comparisons',
30
+ category: 'Path handling',
31
+ bans: 'raw `path.*()` results in string comparisons',
32
+ useInstead: 'wrap in `toForwardSlash()`',
33
+ subpath: '/path',
30
34
  recommended: true,
35
+ recommendedSeverity: 'error',
31
36
  },
32
37
  messages: {
33
38
  normalizePathOperation:
@@ -25,9 +25,14 @@ module.exports = {
25
25
  meta: {
26
26
  type: 'problem',
27
27
  docs: {
28
- description: 'Disallow using path.sep in string operations',
29
- category: 'Cross-platform compatibility',
28
+ description:
29
+ 'Disallow using path.sep in string operations',
30
+ category: 'Path handling',
31
+ bans: '`path.sep` embedded in a string literal',
32
+ useInstead: '`toForwardSlash()`',
33
+ subpath: '/path',
30
34
  recommended: true,
35
+ recommendedSeverity: 'error',
31
36
  },
32
37
  messages: {
33
38
  noPathSep:
@@ -68,9 +68,14 @@ module.exports = {
68
68
  meta: {
69
69
  type: 'problem',
70
70
  docs: {
71
- description: 'Disallow direct path.startsWith() without normalization',
72
- category: 'Cross-platform compatibility',
71
+ description:
72
+ 'Disallow direct path.startsWith() without normalization',
73
+ category: 'Path handling',
74
+ bans: '`path.startsWith()` on a raw path',
75
+ useInstead: '`toForwardSlash()` first',
76
+ subpath: '/path',
73
77
  recommended: true,
78
+ recommendedSeverity: 'error',
74
79
  },
75
80
  messages: {
76
81
  useNormalizeHelper:
@@ -93,8 +93,18 @@ module.exports = {
93
93
  docs: {
94
94
  description:
95
95
  'Forbid process.exit() inside a phase entry point, where it would end the whole orchestrated run',
96
- category: 'Agentic Code Safety',
97
- recommended: true,
96
+ category: 'Process control',
97
+ bans: '`process.exit()` inside a function named `…Phase`',
98
+ useInstead: 'return the exit code from the phase; only the command wrapper exits',
99
+ // Not in `recommended`: it keys on a NAMING CONVENTION that is VAT's, not a
100
+ // portable fact. The hazard is real and general (an in-process orchestrator
101
+ // whose step calls `process.exit()` silently skips every later step), but
102
+ // the marker is the `…Phase` suffix, and an adopter with an unrelated
103
+ // `computeRenderPhase()` that legitimately exits would get a finding they
104
+ // cannot act on. VAT enables it explicitly, repo-wide, so a NEW phase is
105
+ // covered the moment it is named like one.
106
+ recommended: false,
107
+ recommendedSeverity: 'error',
98
108
  },
99
109
  fixable: null,
100
110
  schema: [],