@vibe-agent-toolkit/utils 0.2.0-rc.1 → 0.2.0-rc.11

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 (270) hide show
  1. package/README.md +69 -23
  2. package/dist/asset-reference.js +3 -11
  3. package/dist/asset-reference.js.map +1 -1
  4. package/dist/compare-code-units.d.ts +28 -0
  5. package/dist/compare-code-units.d.ts.map +1 -0
  6. package/dist/compare-code-units.js +33 -0
  7. package/dist/compare-code-units.js.map +1 -0
  8. package/dist/crawl-timing.d.ts +657 -0
  9. package/dist/crawl-timing.d.ts.map +1 -0
  10. package/dist/crawl-timing.js +765 -0
  11. package/dist/crawl-timing.js.map +1 -0
  12. package/dist/crawl.d.ts +2 -4
  13. package/dist/crawl.d.ts.map +1 -1
  14. package/dist/crawl.js +2 -4
  15. package/dist/crawl.js.map +1 -1
  16. package/dist/dirent-kind.d.ts +83 -0
  17. package/dist/dirent-kind.d.ts.map +1 -0
  18. package/dist/dirent-kind.js +129 -0
  19. package/dist/dirent-kind.js.map +1 -0
  20. package/dist/entrypoint.d.ts +54 -0
  21. package/dist/entrypoint.d.ts.map +1 -0
  22. package/dist/entrypoint.js +71 -0
  23. package/dist/entrypoint.js.map +1 -0
  24. package/dist/env-flag.d.ts +57 -0
  25. package/dist/env-flag.d.ts.map +1 -0
  26. package/dist/env-flag.js +68 -0
  27. package/dist/env-flag.js.map +1 -0
  28. package/dist/errors/errno.d.ts +51 -0
  29. package/dist/errors/errno.d.ts.map +1 -0
  30. package/dist/errors/errno.js +112 -0
  31. package/dist/errors/errno.js.map +1 -0
  32. package/dist/errors/vat-error.d.ts +60 -0
  33. package/dist/errors/vat-error.d.ts.map +1 -0
  34. package/dist/errors/vat-error.js +86 -0
  35. package/dist/errors/vat-error.js.map +1 -0
  36. package/dist/file-crawler.d.ts +60 -0
  37. package/dist/file-crawler.d.ts.map +1 -1
  38. package/dist/file-crawler.js +133 -29
  39. package/dist/file-crawler.js.map +1 -1
  40. package/dist/fs/file-hash.d.ts.map +1 -1
  41. package/dist/fs/file-hash.js +0 -1
  42. package/dist/fs/file-hash.js.map +1 -1
  43. package/dist/fs-utils.d.ts +422 -191
  44. package/dist/fs-utils.d.ts.map +1 -1
  45. package/dist/fs-utils.js +534 -204
  46. package/dist/fs-utils.js.map +1 -1
  47. package/dist/fs.d.ts +6 -2
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +10 -7
  50. package/dist/fs.js.map +1 -1
  51. package/dist/git-run.d.ts +167 -0
  52. package/dist/git-run.d.ts.map +1 -0
  53. package/dist/git-run.js +154 -0
  54. package/dist/git-run.js.map +1 -0
  55. package/dist/git-snapshot.d.ts +199 -0
  56. package/dist/git-snapshot.d.ts.map +1 -0
  57. package/dist/git-snapshot.js +250 -0
  58. package/dist/git-snapshot.js.map +1 -0
  59. package/dist/git-tracker.d.ts +166 -3
  60. package/dist/git-tracker.d.ts.map +1 -1
  61. package/dist/git-tracker.js +250 -22
  62. package/dist/git-tracker.js.map +1 -1
  63. package/dist/git-utils.d.ts +84 -1
  64. package/dist/git-utils.d.ts.map +1 -1
  65. package/dist/git-utils.js +208 -82
  66. package/dist/git-utils.js.map +1 -1
  67. package/dist/git.d.ts +13 -4
  68. package/dist/git.d.ts.map +1 -1
  69. package/dist/git.js +12 -4
  70. package/dist/git.js.map +1 -1
  71. package/dist/gitignore-checker.d.ts.map +1 -1
  72. package/dist/gitignore-checker.js +15 -6
  73. package/dist/gitignore-checker.js.map +1 -1
  74. package/dist/glob/glob-pattern.js +5 -5
  75. package/dist/glob/glob-pattern.js.map +1 -1
  76. package/dist/index.d.ts +32 -20
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +95 -50
  79. package/dist/index.js.map +1 -1
  80. package/dist/listing-refusal.d.ts +112 -0
  81. package/dist/listing-refusal.d.ts.map +1 -0
  82. package/dist/listing-refusal.js +105 -0
  83. package/dist/listing-refusal.js.map +1 -0
  84. package/dist/path-containment.d.ts +79 -0
  85. package/dist/path-containment.d.ts.map +1 -0
  86. package/dist/path-containment.js +128 -0
  87. package/dist/path-containment.js.map +1 -0
  88. package/dist/path-core.d.ts +113 -18
  89. package/dist/path-core.d.ts.map +1 -1
  90. package/dist/path-core.js +139 -23
  91. package/dist/path-core.js.map +1 -1
  92. package/dist/path-utils.d.ts.map +1 -1
  93. package/dist/path-utils.js +42 -47
  94. package/dist/path-utils.js.map +1 -1
  95. package/dist/path.d.ts +1 -1
  96. package/dist/path.d.ts.map +1 -1
  97. package/dist/path.js +1 -1
  98. package/dist/path.js.map +1 -1
  99. package/dist/process.d.ts +7 -0
  100. package/dist/process.d.ts.map +1 -1
  101. package/dist/process.js +7 -0
  102. package/dist/process.js.map +1 -1
  103. package/dist/project-utils.d.ts.map +1 -1
  104. package/dist/project-utils.js +15 -9
  105. package/dist/project-utils.js.map +1 -1
  106. package/dist/project.d.ts +7 -5
  107. package/dist/project.d.ts.map +1 -1
  108. package/dist/project.js +7 -5
  109. package/dist/project.js.map +1 -1
  110. package/dist/safe-exec.d.ts +40 -11
  111. package/dist/safe-exec.d.ts.map +1 -1
  112. package/dist/safe-exec.js +111 -25
  113. package/dist/safe-exec.js.map +1 -1
  114. package/dist/skill-targets.d.ts +2 -2
  115. package/dist/skill-targets.js +2 -2
  116. package/dist/skill-test/auth-resolver.d.ts +10 -2
  117. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  118. package/dist/skill-test/auth-resolver.js +26 -11
  119. package/dist/skill-test/auth-resolver.js.map +1 -1
  120. package/dist/skill-test/index.d.ts +1 -1
  121. package/dist/skill-test/index.d.ts.map +1 -1
  122. package/dist/skill-test/index.js +1 -1
  123. package/dist/skill-test/index.js.map +1 -1
  124. package/dist/skill-test/spawn-claude.d.ts +30 -1
  125. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  126. package/dist/skill-test/spawn-claude.js +71 -5
  127. package/dist/skill-test/spawn-claude.js.map +1 -1
  128. package/dist/skill-test/transcript.d.ts +41 -2
  129. package/dist/skill-test/transcript.d.ts.map +1 -1
  130. package/dist/skill-test/transcript.js +55 -10
  131. package/dist/skill-test/transcript.js.map +1 -1
  132. package/dist/spawn-hardened.js +0 -1
  133. package/dist/spawn-hardened.js.map +1 -1
  134. package/dist/stdio-blocking.js +13 -14
  135. package/dist/stdio-blocking.js.map +1 -1
  136. package/dist/test-helpers.d.ts +135 -100
  137. package/dist/test-helpers.d.ts.map +1 -1
  138. package/dist/test-helpers.js +224 -146
  139. package/dist/test-helpers.js.map +1 -1
  140. package/dist/testing/executables.d.ts +32 -0
  141. package/dist/testing/executables.d.ts.map +1 -0
  142. package/dist/testing/executables.js +70 -0
  143. package/dist/testing/executables.js.map +1 -0
  144. package/dist/testing/hostile-tree.d.ts +79 -0
  145. package/dist/testing/hostile-tree.d.ts.map +1 -0
  146. package/dist/testing/hostile-tree.js +164 -0
  147. package/dist/testing/hostile-tree.js.map +1 -0
  148. package/dist/testing/platform-gates.d.ts +31 -0
  149. package/dist/testing/platform-gates.d.ts.map +1 -0
  150. package/dist/testing/platform-gates.js +31 -0
  151. package/dist/testing/platform-gates.js.map +1 -0
  152. package/dist/testing/temp-dir.d.ts +255 -0
  153. package/dist/testing/temp-dir.d.ts.map +1 -0
  154. package/dist/testing/temp-dir.js +363 -0
  155. package/dist/testing/temp-dir.js.map +1 -0
  156. package/dist/testing.d.ts +94 -3
  157. package/dist/testing.d.ts.map +1 -1
  158. package/dist/testing.js +119 -3
  159. package/dist/testing.js.map +1 -1
  160. package/dist/text-content.d.ts +226 -0
  161. package/dist/text-content.d.ts.map +1 -0
  162. package/dist/text-content.js +418 -0
  163. package/dist/text-content.js.map +1 -0
  164. package/dist/text-file.d.ts +84 -0
  165. package/dist/text-file.d.ts.map +1 -0
  166. package/dist/text-file.js +118 -0
  167. package/dist/text-file.js.map +1 -0
  168. package/dist/text.d.ts +16 -0
  169. package/dist/text.d.ts.map +1 -0
  170. package/dist/text.js +16 -0
  171. package/dist/text.js.map +1 -0
  172. package/dist/timing-dump.d.ts +136 -0
  173. package/dist/timing-dump.d.ts.map +1 -0
  174. package/dist/timing-dump.js +229 -0
  175. package/dist/timing-dump.js.map +1 -0
  176. package/dist/windows-shell.d.ts +0 -1
  177. package/dist/windows-shell.d.ts.map +1 -1
  178. package/dist/windows-shell.js +0 -1
  179. package/dist/windows-shell.js.map +1 -1
  180. package/dist/zod-introspection.js +2 -2
  181. package/dist/zod-introspection.js.map +1 -1
  182. package/eslint/README.md +223 -49
  183. package/eslint/index.cjs +111 -97
  184. package/eslint/index.d.cts +16 -6
  185. package/eslint/rules/callee-name.cjs +23 -0
  186. package/eslint/rules/commands-import-boundary.cjs +148 -0
  187. package/eslint/rules/dead-import.cjs +1 -1
  188. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  189. package/eslint/rules/eslint-rule-factory.cjs +5 -2
  190. package/eslint/rules/exempt-path-matcher.cjs +2 -1
  191. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  192. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  193. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  194. package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
  195. package/eslint/rules/no-blind-catch.cjs +171 -0
  196. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  197. package/eslint/rules/no-decaying-referent.cjs +217 -0
  198. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  199. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  200. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  201. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  202. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  203. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  204. package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
  205. package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
  206. package/eslint/rules/no-literal-process-exit.cjs +230 -0
  207. package/eslint/rules/no-manual-path-normalize.cjs +136 -107
  208. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  209. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  210. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  211. package/eslint/rules/no-path-startswith.cjs +7 -2
  212. package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
  213. package/eslint/rules/no-raw-node-path.cjs +563 -0
  214. package/eslint/rules/no-raw-text-decode.cjs +305 -0
  215. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  216. package/eslint/rules/no-self-package-import.cjs +168 -0
  217. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  218. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  219. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  220. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  221. package/eslint/rules/no-version-literal.cjs +151 -0
  222. package/eslint/rules/prefer-startswith-over-regex.cjs +9 -0
  223. package/eslint/rules/require-justified-skip.cjs +8 -2
  224. package/eslint/rules/safe-import.cjs +1 -1
  225. package/package.json +16 -16
  226. package/dist/link-auth/build-headers.d.ts +0 -34
  227. package/dist/link-auth/build-headers.d.ts.map +0 -1
  228. package/dist/link-auth/build-headers.js +0 -58
  229. package/dist/link-auth/build-headers.js.map +0 -1
  230. package/dist/link-auth/expand-macro.d.ts +0 -38
  231. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  232. package/dist/link-auth/expand-macro.js +0 -133
  233. package/dist/link-auth/expand-macro.js.map +0 -1
  234. package/dist/link-auth/macros.yaml +0 -50
  235. package/dist/link-auth/resolve-token.d.ts +0 -83
  236. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  237. package/dist/link-auth/resolve-token.js +0 -101
  238. package/dist/link-auth/resolve-token.js.map +0 -1
  239. package/dist/link-auth/resolve.d.ts +0 -102
  240. package/dist/link-auth/resolve.d.ts.map +0 -1
  241. package/dist/link-auth/resolve.js +0 -66
  242. package/dist/link-auth/resolve.js.map +0 -1
  243. package/dist/link-auth/rewrite.d.ts +0 -52
  244. package/dist/link-auth/rewrite.d.ts.map +0 -1
  245. package/dist/link-auth/rewrite.js +0 -102
  246. package/dist/link-auth/rewrite.js.map +0 -1
  247. package/dist/link-auth/select-provider.d.ts +0 -30
  248. package/dist/link-auth/select-provider.d.ts.map +0 -1
  249. package/dist/link-auth/select-provider.js +0 -55
  250. package/dist/link-auth/select-provider.js.map +0 -1
  251. package/dist/link-auth/template.d.ts +0 -40
  252. package/dist/link-auth/template.d.ts.map +0 -1
  253. package/dist/link-auth/template.js +0 -89
  254. package/dist/link-auth/template.js.map +0 -1
  255. package/dist/link-auth/transforms.d.ts +0 -46
  256. package/dist/link-auth/transforms.d.ts.map +0 -1
  257. package/dist/link-auth/transforms.js +0 -52
  258. package/dist/link-auth/transforms.js.map +0 -1
  259. package/dist/template-entry.d.ts +0 -10
  260. package/dist/template-entry.d.ts.map +0 -1
  261. package/dist/template-entry.js +0 -10
  262. package/dist/template-entry.js.map +0 -1
  263. package/dist/template.d.ts +0 -7
  264. package/dist/template.d.ts.map +0 -1
  265. package/dist/template.js +0 -18
  266. package/dist/template.js.map +0 -1
  267. package/eslint/rules/no-path-join.cjs +0 -14
  268. package/eslint/rules/no-path-relative.cjs +0 -14
  269. package/eslint/rules/no-path-resolve.cjs +0 -14
  270. package/eslint/rules/path-function-rule-factory.cjs +0 -496
@@ -0,0 +1,230 @@
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
+ * ## 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
+ * - `derived.legacy` names the files not yet migrated. It is a RATCHET asserted
39
+ * both ways: a listed file that no longer decides a code by hand is itself an
40
+ * error (`staleLegacy`), so the list can only shrink and never holds a dead entry.
41
+ *
42
+ * ⚠️ What this cannot see: a document verb ending on a bare `ExitCode.OK` over a
43
+ * document that says `error`. That is a runtime fact, pinned by the exit-code
44
+ * matrix system test, not a syntactic one.
45
+ *
46
+ * @example
47
+ * // BAD — 1 means what, exactly?
48
+ * process.exit(1);
49
+ *
50
+ * // BAD under `derived` — the call site decides what the document means
51
+ * process.exit(errors > 0 ? ExitCode.FINDINGS : ExitCode.OK);
52
+ *
53
+ * // GOOD — derived from what was published
54
+ * process.exit(exitCodeForReport(report));
55
+ */
56
+
57
+ 'use strict';
58
+
59
+ const { createExemptDirectoryMatcher, createExemptPathMatcher, isTestFile } = require('./exempt-path-matcher.cjs');
60
+
61
+ /** The members a call site may name outright: neither is a claim about a document. */
62
+ const UNDERIVED_MEMBERS = new Set(['OK', 'ERROR']);
63
+
64
+ /** Whether `node` is `ExitCode.<name>` (non-computed). */
65
+ function isExitCodeMember(node, name) {
66
+ return (
67
+ node.type === 'MemberExpression' &&
68
+ !node.computed &&
69
+ node.object.type === 'Identifier' &&
70
+ node.object.name === 'ExitCode' &&
71
+ node.property.type === 'Identifier' &&
72
+ (name === undefined ? UNDERIVED_MEMBERS.has(node.property.name) : node.property.name === name)
73
+ );
74
+ }
75
+
76
+ /** The called function's own name — `f(…)` or `x.f(…)` — or null. */
77
+ function calleeName(node) {
78
+ if (node.type !== 'CallExpression') return null;
79
+ const { callee } = node;
80
+ if (callee.type === 'Identifier') return callee.name;
81
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
82
+ return callee.property.name;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** Whether `node` is the member expression `process.<name>` (non-computed). */
88
+ function isProcessMember(node, name) {
89
+ return (
90
+ node.type === 'MemberExpression' &&
91
+ !node.computed &&
92
+ node.object.type === 'Identifier' &&
93
+ node.object.name === 'process' &&
94
+ node.property.type === 'Identifier' &&
95
+ node.property.name === name
96
+ );
97
+ }
98
+
99
+ /**
100
+ * The first numeric literal reachable through the expression's value
101
+ * branches, or null. Walks conditionals, nullish/logical fallbacks, unary
102
+ * minus, and TypeScript wrappers — every path a number can take to become the
103
+ * exit code without passing through a name.
104
+ */
105
+ function numericLiteralLeaf(expr) {
106
+ switch (expr.type) {
107
+ case 'Literal':
108
+ return typeof expr.value === 'number' ? expr : null;
109
+ case 'UnaryExpression':
110
+ return numericLiteralLeaf(expr.argument);
111
+ case 'ConditionalExpression':
112
+ return numericLiteralLeaf(expr.consequent) ?? numericLiteralLeaf(expr.alternate);
113
+ case 'LogicalExpression':
114
+ return numericLiteralLeaf(expr.left) ?? numericLiteralLeaf(expr.right);
115
+ case 'TSAsExpression':
116
+ case 'TSNonNullExpression':
117
+ case 'TSSatisfiesExpression':
118
+ return numericLiteralLeaf(expr.expression);
119
+ default:
120
+ return null;
121
+ }
122
+ }
123
+
124
+ module.exports = {
125
+ meta: {
126
+ type: 'problem',
127
+ docs: {
128
+ description:
129
+ 'Disallow process.exit(<number>) and process.exitCode = <number> — name the meaning with ' +
130
+ 'the ExitCode enum so every command shares one exit contract',
131
+ recommended: false,
132
+ recommendedSeverity: 'warn',
133
+ },
134
+ schema: [
135
+ {
136
+ type: 'object',
137
+ properties: {
138
+ allow: { type: 'array', items: { type: 'string' }, uniqueItems: true },
139
+ derived: {
140
+ type: 'object',
141
+ properties: {
142
+ paths: { type: 'array', items: { type: 'string' }, uniqueItems: true },
143
+ calls: { type: 'array', items: { type: 'string' }, uniqueItems: true },
144
+ legacy: { type: 'array', items: { type: 'string' }, uniqueItems: true },
145
+ },
146
+ required: ['paths', 'calls', 'legacy'],
147
+ additionalProperties: false,
148
+ },
149
+ },
150
+ additionalProperties: false,
151
+ },
152
+ ],
153
+ messages: {
154
+ literalExit:
155
+ 'process.exit({{literal}}) re-decides the exit contract at this call site. Use the ' +
156
+ 'ExitCode enum from @vibe-agent-toolkit/schema — process.exit(ExitCode.X) — so the code ' +
157
+ 'means the same thing in every command.',
158
+ literalExitCode:
159
+ 'process.exitCode = {{literal}} re-decides the exit contract at this site. Assign an ' +
160
+ 'ExitCode member from @vibe-agent-toolkit/schema instead.',
161
+ findingsNotDerived:
162
+ 'ExitCode.FINDINGS is a claim about a published document ("what it examined failed its ' +
163
+ 'gate"). Derive it: process.exit(exitCodeForReport(document)) — never name it at a call site.',
164
+ exitNotDerived:
165
+ 'This exit code is decided here, beside the document, where it can disagree with it. Exit ' +
166
+ 'with ExitCode.OK, ExitCode.ERROR, or a derivation: {{calls}}.',
167
+ staleLegacy:
168
+ 'This file is listed in no-literal-process-exit derived.legacy but decides no exit code by ' +
169
+ 'hand any more. Remove it from the list — the ratchet only shrinks.',
170
+ },
171
+ },
172
+
173
+ create(context) {
174
+ const allow = context.options?.[0]?.allow ?? [];
175
+ const derived = context.options?.[0]?.derived;
176
+ const filename = context.filename ?? context.getFilename();
177
+ if (createExemptPathMatcher(allow)(filename)) {
178
+ return {};
179
+ }
180
+
181
+ const inDerived = derived !== undefined
182
+ && !isTestFile(filename)
183
+ && createExemptDirectoryMatcher(derived.paths)(filename);
184
+ const isLegacy = inDerived && createExemptPathMatcher(derived.legacy)(filename);
185
+ const derivations = new Set(derived?.calls ?? []);
186
+ let handDecided = 0;
187
+
188
+ /** Report a derivation violation — or, in a legacy file, only count it. */
189
+ function reportDerived(node, messageId) {
190
+ handDecided += 1;
191
+ if (isLegacy) return;
192
+ context.report({ node, messageId, data: { calls: [...derivations].join(', ') } });
193
+ }
194
+
195
+ /** @returns {boolean} Whether a literal was reported (so nothing else is). */
196
+ function reportLiteral(node, expr, messageId) {
197
+ const leaf = numericLiteralLeaf(expr);
198
+ if (leaf === null) return false;
199
+ context.report({ node, messageId, data: { literal: String(leaf.raw ?? leaf.value) } });
200
+ return true;
201
+ }
202
+
203
+ function checkExitValue(node, expr, literalMessageId) {
204
+ if (reportLiteral(node, expr, literalMessageId) || !inDerived) return;
205
+ if (isExitCodeMember(expr) || derivations.has(calleeName(expr))) return;
206
+ // `ExitCode.FINDINGS` itself is reported by the member visitor below.
207
+ if (isExitCodeMember(expr, 'FINDINGS')) return;
208
+ reportDerived(node, 'exitNotDerived');
209
+ }
210
+
211
+ return {
212
+ CallExpression(node) {
213
+ if (isProcessMember(node.callee, 'exit') && node.arguments.length > 0) {
214
+ checkExitValue(node, node.arguments[0], 'literalExit');
215
+ }
216
+ },
217
+ AssignmentExpression(node) {
218
+ if (isProcessMember(node.left, 'exitCode')) {
219
+ checkExitValue(node, node.right, 'literalExitCode');
220
+ }
221
+ },
222
+ MemberExpression(node) {
223
+ if (inDerived && isExitCodeMember(node, 'FINDINGS')) reportDerived(node, 'findingsNotDerived');
224
+ },
225
+ 'Program:exit'(node) {
226
+ if (isLegacy && handDecided === 0) context.report({ node, messageId: 'staleLegacy' });
227
+ },
228
+ };
229
+ },
230
+ };
@@ -1,16 +1,32 @@
1
1
  /**
2
- * ESLint rule to enforce using toForwardSlash() instead of manual normalization
2
+ * ESLint rule to enforce the forward-slash converters instead of hand-rolled
3
+ * normalization.
3
4
  *
4
- * Detects manual path normalization patterns and suggests using the utility function.
5
+ * Two converters, because a backslash means two different things:
6
+ *
7
+ * - `toForwardSlash(p)` — a NATIVE path (from `fs`, `path.*`, git). Converts
8
+ * only where the host's separator is a backslash; on POSIX a backslash is a
9
+ * filename character and is kept. `split(path.sep).join('/')` is exactly
10
+ * this, so it autofixes here.
11
+ * - `toForwardSlashAnyPlatform(text)` — AUTHOR-WRITTEN text (an href, a glob, a
12
+ * config value, an archive entry name). Converts every backslash on every
13
+ * host. A literal-backslash `split('\\').join('/')`, `replaceAll('\\', '/')`
14
+ * or `replace(/\\/g, '/')` is exactly this, so each autofixes here — never to
15
+ * `toForwardSlash`, which would silently stop converting on POSIX.
16
+ *
17
+ * Whether a given literal-backslash site is really author text is the author's
18
+ * call; the fix preserves behaviour, and a native-path site should then be
19
+ * switched to `toForwardSlash` by hand.
5
20
  *
6
21
  * @example
7
22
  * // ❌ BAD - manual normalization
8
- * const normalized = relativePath.split(path.sep).join('/');
9
- * const normalized = somePath.split('\\').join('/');
23
+ * const a = relativePath.split(path.sep).join('/');
24
+ * const b = href.replaceAll('\\', '/');
10
25
  *
11
- * // ✅ GOOD - use utility function
12
- * import { toForwardSlash } from '@vibe-agent-toolkit/utils/path';
13
- * const normalized = toForwardSlash(relativePath);
26
+ * // ✅ GOOD - use the utility functions
27
+ * import { toForwardSlash, toForwardSlashAnyPlatform } from '@vibe-agent-toolkit/utils/path';
28
+ * const a = toForwardSlash(relativePath);
29
+ * const b = toForwardSlashAnyPlatform(href);
14
30
  */
15
31
 
16
32
  const {
@@ -26,22 +42,85 @@ const {
26
42
  resolveSafeModule,
27
43
  } = require('./safe-import.cjs');
28
44
 
29
- const SAFE_FN = 'toForwardSlash';
45
+ const NATIVE_FN = 'toForwardSlash';
46
+ const ANY_PLATFORM_FN = 'toForwardSlashAnyPlatform';
47
+ const MESSAGE_FOR = { [NATIVE_FN]: 'useToForwardSlash', [ANY_PLATFORM_FN]: 'useToForwardSlashAnyPlatform' };
30
48
  const PATH_MODULES = new Set(['node:path', 'path']);
49
+ const BACKSLASH = '\\';
50
+
51
+ /** `.name(...)` on some receiver, with exactly `arity` arguments? */
52
+ function isMethodCall(node, name, arity) {
53
+ return (
54
+ node?.type === 'CallExpression' &&
55
+ node.callee.type === 'MemberExpression' &&
56
+ node.callee.property.name === name &&
57
+ node.arguments.length === arity
58
+ );
59
+ }
60
+
61
+ function isStringLiteral(node, value) {
62
+ return node.type === 'Literal' && node.value === value;
63
+ }
64
+
65
+ /** `/\\/g` — one literal backslash, global. */
66
+ function isGlobalBackslashRegex(node) {
67
+ return node.type === 'Literal' && node.regex?.pattern === String.raw`\\` && node.regex.flags.includes('g');
68
+ }
69
+
70
+ function isPathSep(node) {
71
+ return node.type === 'MemberExpression' && node.object.name === 'path' && node.property.name === 'sep';
72
+ }
73
+
74
+ /**
75
+ * `<x>.split(<sep>).join('/')` → the converter it is equivalent to.
76
+ *
77
+ * Splitting on a TWO-backslash SEQUENCE (e.g. collapsing a UNC server prefix)
78
+ * is a different operation that neither converter matches, so it is not
79
+ * reported.
80
+ */
81
+ function matchSplitJoin(node) {
82
+ if (!isMethodCall(node, 'join', 1) || !isStringLiteral(node.arguments[0], '/')) return undefined;
83
+ const split = node.callee.object;
84
+ if (!isMethodCall(split, 'split', 1)) return undefined;
85
+ const [separator] = split.arguments;
86
+ if (isPathSep(separator)) return { receiver: split.callee.object, fn: NATIVE_FN };
87
+ if (isStringLiteral(separator, BACKSLASH)) return { receiver: split.callee.object, fn: ANY_PLATFORM_FN };
88
+ return undefined;
89
+ }
90
+
91
+ /** `<x>.replaceAll('\\', '/')` or `<x>.replace(/\\/g, '/')` → the any-platform converter. */
92
+ function matchReplace(node) {
93
+ const isReplace = isMethodCall(node, 'replace', 2) || isMethodCall(node, 'replaceAll', 2);
94
+ if (!isReplace || !isStringLiteral(node.arguments[1], '/')) return undefined;
95
+ const [pattern] = node.arguments;
96
+ const allBackslashes =
97
+ isGlobalBackslashRegex(pattern) ||
98
+ (node.callee.property.name === 'replaceAll' && isStringLiteral(pattern, BACKSLASH));
99
+ return allBackslashes ? { receiver: node.callee.object, fn: ANY_PLATFORM_FN } : undefined;
100
+ }
31
101
 
32
102
  module.exports = {
33
103
  meta: {
34
104
  type: 'problem',
35
105
  docs: {
36
- description: 'Disallow manual path normalization patterns',
37
- category: 'Cross-platform compatibility',
106
+ description:
107
+ 'Disallow manual path normalization patterns',
108
+ category: 'Path handling',
109
+ bans: "hand-rolled `.replace(/\\\\/g, '/')` / `split(path.sep).join('/')`",
110
+ useInstead: '`toForwardSlash()` (native paths) / `toForwardSlashAnyPlatform()` (authored text)',
111
+ subpath: '/path',
38
112
  recommended: true,
113
+ recommendedSeverity: 'error',
39
114
  },
40
115
  fixable: 'code',
41
116
  messages: {
42
117
  useToForwardSlash:
43
118
  'Use toForwardSlash() from {{safeModule}} instead of manual path normalization. ' +
44
- 'Manual normalization is error-prone and less maintainable.',
119
+ 'It converts a native path only where the host separator is a backslash.',
120
+ useToForwardSlashAnyPlatform:
121
+ 'Use toForwardSlashAnyPlatform() from {{safeModule}} instead of a hand-rolled backslash replace. ' +
122
+ 'If this string is a native filesystem/git path rather than authored text, use toForwardSlash() — ' +
123
+ 'on POSIX a backslash is a filename character.',
45
124
  [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
46
125
  },
47
126
  schema: [SAFE_MODULE_ONLY_SCHEMA],
@@ -50,24 +129,43 @@ module.exports = {
50
129
  create(context) {
51
130
  const sourceCode = context.getSourceCode();
52
131
  const targetModule = resolveSafeModule(context, SAFE_PATH_MODULE);
53
- // Seeded from SCOPE: a file that already imports `toForwardSlash` from the
54
- // barrel must have the call rewritten WITHOUT gaining a second binding of
55
- // the same name — that is a SyntaxError. See `safe-import.cjs`.
56
- let hasToForwardSlashImport = isNameAlreadyBound(sourceCode, SAFE_FN);
132
+ // Seeded from SCOPE: a file that already binds the name must have the call
133
+ // rewritten WITHOUT gaining a second binding of the same name — that is a
134
+ // SyntaxError. See `safe-import.cjs`.
135
+ const bound = {
136
+ [NATIVE_FN]: isNameAlreadyBound(sourceCode, NATIVE_FN),
137
+ [ANY_PLATFORM_FN]: isNameAlreadyBound(sourceCode, ANY_PLATFORM_FN),
138
+ };
57
139
  // Never mutated — the dead-import leg must not be armed by a flag that a
58
140
  // suppressed report's `fix()` can spend. See `dead-import.cjs`.
59
- const safeBoundInSource = hasToForwardSlashImport;
141
+ const safeBoundInSource = bound[NATIVE_FN];
60
142
  // The dead-import leg's OTHER gate: a `toForwardSlash(…)` call is the text
61
- // this fixer writes, and the only evidence available that it wrote it here.
62
- // Without it, any file with `toForwardSlash` in scope armed the leg — see
63
- // `dead-import.cjs`. Read from the source, never from a `fix()`.
143
+ // this fixer writes for `path.sep`, and the only evidence available that it
144
+ // wrote it here. Read from the source, never from a `fix()`.
64
145
  let safeReplacementCalled = false;
65
146
  let utilsImportNode = null;
66
147
  // `path.sep` is the last `path.*` reference in plenty of files, and
67
- // `toForwardSlash(raw)` consumes it — leaving the same dead `node:path`
68
- // binding the `safePath` rules used to leave.
148
+ // `toForwardSlash(raw)` consumes it — leaving a dead `node:path` binding.
69
149
  const pathImportNodes = [];
70
150
 
151
+ /**
152
+ * Add `fn` to the import, when nothing binds it yet.
153
+ *
154
+ * NOT latched: two reports insert identical text at the identical anchor, so
155
+ * ESLint applies one and drops the other as overlapping. Latching is not
156
+ * free — ESLint runs `fix()` for a SUPPRESSED problem before the
157
+ * `eslint-disable` filter discards it, so a latch could be spent by a report
158
+ * that is then thrown away.
159
+ */
160
+ function importFix(fixer, fn) {
161
+ if (bound[fn]) return [];
162
+ if (utilsImportNode) {
163
+ return [fixer.insertTextAfter(utilsImportNode.specifiers.at(-1), `, ${fn}`)];
164
+ }
165
+ const newImport = `import { ${fn} } from '${targetModule}';\n`;
166
+ return [insertAboveWithComments(fixer, sourceCode, sourceCode.ast.body[0], newImport)];
167
+ }
168
+
71
169
  return {
72
170
  'Program:exit'() {
73
171
  reportDeadUnsafeImports(
@@ -83,99 +181,30 @@ module.exports = {
83
181
  if (PATH_MODULES.has(node.source.value)) {
84
182
  pathImportNodes.push(node);
85
183
  }
86
- if (node.source.value === targetModule) {
87
- utilsImportNode = node;
88
- for (const spec of node.specifiers) {
89
- if (spec.type === 'ImportSpecifier' && spec.imported.name === SAFE_FN) {
90
- hasToForwardSlashImport = true;
91
- }
184
+ if (node.source.value !== targetModule) return;
185
+ utilsImportNode = node;
186
+ for (const spec of node.specifiers) {
187
+ if (spec.type === 'ImportSpecifier' && spec.imported.name in bound) {
188
+ bound[spec.imported.name] = true;
92
189
  }
93
190
  }
94
191
  },
95
192
 
96
193
  CallExpression(node) {
97
- if (node.callee.type === 'Identifier' && node.callee.name === SAFE_FN) {
194
+ if (node.callee.type === 'Identifier' && node.callee.name === NATIVE_FN) {
98
195
  safeReplacementCalled = true;
99
196
  }
100
-
101
- // Check for .split(...).join('/') pattern
102
- if (
103
- node.callee.type === 'MemberExpression' &&
104
- node.callee.property.name === 'join' &&
105
- node.arguments.length === 1 &&
106
- node.arguments[0].type === 'Literal' &&
107
- node.arguments[0].value === '/'
108
- ) {
109
- // Check if the object is a .split() call
110
- const splitCall = node.callee.object;
111
- if (
112
- splitCall.type === 'CallExpression' &&
113
- splitCall.callee.type === 'MemberExpression' &&
114
- splitCall.callee.property.name === 'split' &&
115
- splitCall.arguments.length === 1
116
- ) {
117
- const splitArg = splitCall.arguments[0];
118
-
119
- // Split on path.sep, or on a single backslash character
120
- // (source literal '\\'). Splitting on a two-backslash SEQUENCE
121
- // (source literal '\\\\', decoded value: two backslash characters)
122
- // is a different, rarer operation -- e.g. collapsing a UNC path's
123
- // leading double-backslash server prefix -- and toForwardSlash()
124
- // is not equivalent to it. Autofixing that case would silently
125
- // change program behavior, so it is deliberately excluded here.
126
- const isSplittingByPathSep =
127
- (splitArg.type === 'MemberExpression' &&
128
- splitArg.object.name === 'path' &&
129
- splitArg.property.name === 'sep') ||
130
- (splitArg.type === 'Literal' && splitArg.value === '\\');
131
-
132
- if (isSplittingByPathSep) {
133
- const variableBeingSplit = splitCall.callee.object;
134
-
135
- context.report({
136
- node,
137
- messageId: 'useToForwardSlash',
138
- data: { safeModule: targetModule },
139
- fix(fixer) {
140
- const fixes = [];
141
-
142
- // Replace the entire .split(...).join('/') with toForwardSlash(...)
143
- const originalVar = sourceCode.getText(variableBeingSplit);
144
- fixes.push(fixer.replaceText(node, `${SAFE_FN}(${originalVar})`));
145
-
146
- // Add import if needed
147
- if (!hasToForwardSlashImport) {
148
- if (utilsImportNode) {
149
- // Add to existing utils import
150
- const lastSpecifier = utilsImportNode.specifiers.at(-1);
151
- fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${SAFE_FN}`));
152
- } else {
153
- // Create new import at the top
154
- const firstNode = sourceCode.ast.body[0];
155
- const newImport = `import { ${SAFE_FN} } from '${targetModule}';\n`;
156
- fixes.push(insertAboveWithComments(fixer, sourceCode, firstNode, newImport));
157
- }
158
- // NOT latched. The comment here used to claim that without a
159
- // `hasToForwardSlashImport = true` a second occurrence would
160
- // insert the import twice; an adversarial run could not
161
- // reproduce that at any occurrence count. It cannot happen:
162
- // both reports insert identical text at the identical anchor,
163
- // so the ranges coincide and ESLint applies one and drops the
164
- // other as overlapping.
165
- //
166
- // Latching it is not free, either. ESLint runs `fix()` for a
167
- // SUPPRESSED problem before the `eslint-disable` filter
168
- // discards it, so the first report could spend the flag and
169
- // then be thrown away — leaving later occurrences rewritten
170
- // to a `toForwardSlash` nothing imports.
171
- }
172
-
173
- return fixes;
174
- },
175
- });
176
- }
177
- }
178
- }
197
+ const match = matchSplitJoin(node) ?? matchReplace(node);
198
+ if (!match) return;
199
+ context.report({
200
+ node,
201
+ messageId: MESSAGE_FOR[match.fn],
202
+ data: { safeModule: targetModule },
203
+ fix(fixer) {
204
+ const receiver = sourceCode.getText(match.receiver);
205
+ return [fixer.replaceText(node, `${match.fn}(${receiver})`), ...importFix(fixer, match.fn)];
206
+ },
207
+ });
179
208
  },
180
209
  };
181
210
  },
@@ -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: