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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +51 -0
  25. package/dist/env-flag.d.ts.map +1 -0
  26. package/dist/env-flag.js +62 -0
  27. package/dist/env-flag.js.map +1 -0
  28. package/dist/errors/errno.d.ts +51 -0
  29. package/dist/errors/errno.d.ts.map +1 -0
  30. package/dist/errors/errno.js +112 -0
  31. package/dist/errors/errno.js.map +1 -0
  32. package/dist/errors/vat-error.d.ts +60 -0
  33. package/dist/errors/vat-error.d.ts.map +1 -0
  34. package/dist/errors/vat-error.js +86 -0
  35. package/dist/errors/vat-error.js.map +1 -0
  36. package/dist/file-crawler.d.ts +60 -0
  37. package/dist/file-crawler.d.ts.map +1 -1
  38. package/dist/file-crawler.js +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 +5 -2
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +9 -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 +64 -0
  85. package/dist/path-containment.d.ts.map +1 -0
  86. package/dist/path-containment.js +111 -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 +197 -0
  161. package/dist/text-content.d.ts.map +1 -0
  162. package/dist/text-content.js +354 -0
  163. package/dist/text-content.js.map +1 -0
  164. package/dist/text-file.d.ts +45 -0
  165. package/dist/text-file.d.ts.map +1 -0
  166. package/dist/text-file.js +51 -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 +129 -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,563 @@
1
+ /**
2
+ * ESLint rule: no-raw-node-path
3
+ *
4
+ * Bans the `node:path` functions that `safePath` wraps — `join`, `resolve`,
5
+ * `relative` — and autofixes each call to `safePath.<fn>()` from
6
+ * `@vibe-agent-toolkit/utils/path`. The wrappers add `toForwardSlash()`, so the
7
+ * result never carries the backslashes `node:path` produces on Windows, which
8
+ * is what turns a Map key, a string comparison or a glob match into a
9
+ * platform-dependent bug.
10
+ *
11
+ * ONE rule with a `functions` option table, not one rule id per function. The
12
+ * three functions are one concept — "never touch `node:path` raw" — and every
13
+ * line of this file is shared between them; publishing them as three ids meant
14
+ * three README rows, three suites, three disable vocabularies and a dead-import
15
+ * helper whose whole purpose was cleaning up after the other two.
16
+ *
17
+ * Handles both import styles:
18
+ * - Named: `import { join } from 'node:path'` → `join(...)`
19
+ * - Default/namespace: `import path from 'node:path'` → `path.join(...)`
20
+ *
21
+ * Auto-fixes to `safePath.<fn>()` from the narrow subpath that owns `safePath`,
22
+ * NOT the barrel. See `safe-import.cjs`.
23
+ */
24
+
25
+ const {
26
+ DEAD_UNSAFE_IMPORT,
27
+ DEAD_UNSAFE_IMPORT_MESSAGE,
28
+ reportDeadUnsafeImports,
29
+ } = require('./dead-import.cjs');
30
+ const {
31
+ UNANCHORED_EXEMPT_FILE,
32
+ UNANCHORED_EXEMPT_MESSAGE,
33
+ createConfigurableExemptPathMatcher,
34
+ reportUnanchoredExemptEntries,
35
+ } = require('./exempt-path-matcher.cjs');
36
+ const {
37
+ EXEMPT_AND_SAFE_MODULE_SCHEMA,
38
+ SAFE_PATH_MODULE,
39
+ insertAboveWithComments,
40
+ isNameAlreadyBound,
41
+ resolveSafeModule,
42
+ } = require('./safe-import.cjs');
43
+
44
+ const PATH_MODULES = new Set(['node:path', 'path']);
45
+ const SAFE_OBJECT = 'safePath';
46
+
47
+ /**
48
+ * The `node:path` functions `safePath` wraps, and therefore the only values
49
+ * `functions` accepts: a name outside this list has no `safePath.<fn>` to be
50
+ * rewritten to, and a fixer that wrote one would produce a call on a member
51
+ * that does not exist.
52
+ */
53
+ const WRAPPED_FUNCTIONS = Object.freeze(['join', 'resolve', 'relative']);
54
+
55
+ /**
56
+ * The files allowed to call raw `node:path` functions are whichever ones the
57
+ * CONSUMING repo says implement (or assert) its `safePath` wrappers — declared
58
+ * as `{ exemptFiles: [...] }`, matched at a path-segment boundary (see
59
+ * `exempt-path-matcher.cjs` for why these are not substrings).
60
+ *
61
+ * This list used to be hardcoded to VAT's own `packages/utils/src/path-core.ts`
62
+ * et al. Those paths are meaningless in an adopter's tree and actively harmful
63
+ * as a default — a same-named file at the same repo-relative path would inherit
64
+ * an exemption it never declared. Default: nothing is exempt.
65
+ */
66
+ const exemptMatcherFor = createConfigurableExemptPathMatcher();
67
+
68
+ /**
69
+ * Remove a named import specifier, handling comma cleanup.
70
+ */
71
+ function removeSpecifier(fixer, sourceCode, importNode, spec) {
72
+ if (importNode.specifiers.length === 1) {
73
+ return [fixer.remove(importNode)];
74
+ }
75
+ const comma = sourceCode.getTokenAfter(spec);
76
+ if (comma?.value === ',') {
77
+ return [fixer.removeRange([spec.range[0], comma.range[1]])];
78
+ }
79
+ const commaBefore = sourceCode.getTokenBefore(spec);
80
+ if (commaBefore?.value === ',') {
81
+ return [fixer.removeRange([commaBefore.range[0], spec.range[1]])];
82
+ }
83
+ return [fixer.remove(spec)];
84
+ }
85
+
86
+ /**
87
+ * Track path module specifiers from an import declaration.
88
+ *
89
+ * Two specifier shapes are deliberately NOT tracked, because tracking them is
90
+ * what let the fixer delete them:
91
+ *
92
+ * - **Type-only** (`import { type join, … }` / `import type { join }`). The
93
+ * binding exists only for the type checker; there is no call to rewrite, and
94
+ * removing the specifier silently breaks every `typeof join` that referenced
95
+ * it. `no-undef` cannot see the damage — it is a TYPE reference.
96
+ * - **Aliased** (`import { join as pathJoin }`). The rule never reported
97
+ * `pathJoin(...)` in the first place — `classifyCall` matches on the callee's
98
+ * name — so tracking the specifier bought nothing and cost the whole import:
99
+ * an unrelated unbound `join(` elsewhere in the file made the fixer remove
100
+ * the alias, breaking every working `pathJoin` call site.
101
+ */
102
+ function trackPathImport(node, state) {
103
+ if (node.importKind === 'type') {
104
+ return;
105
+ }
106
+ for (const spec of node.specifiers) {
107
+ if (spec.type === 'ImportSpecifier' && spec.importKind !== 'type' && spec.local.name === spec.imported.name) {
108
+ const fnState = state.byFn.get(spec.imported.name);
109
+ if (fnState) {
110
+ fnState.namedImportSpec = spec;
111
+ fnState.namedImportNode = node;
112
+ }
113
+ }
114
+ if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
115
+ state.defaultImportName = spec.local.name;
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Is `name` re-exported by a bare `export { name }` in this file?
122
+ *
123
+ * Removing the import specifier then leaves the export naming nothing, and the
124
+ * result does not PARSE — `Export 'join' is not defined`. An autofix whose
125
+ * output cannot be parsed is the worst outcome available, so the specifier
126
+ * stays and the call sites are still rewritten. Whatever is left is a lint
127
+ * finding a human can read, not a broken file.
128
+ */
129
+ function isReExported(sourceCode, name) {
130
+ return sourceCode.ast.body.some(
131
+ (node) =>
132
+ node.type === 'ExportNamedDeclaration' &&
133
+ !node.source &&
134
+ node.specifiers.some((spec) => spec.local?.name === name),
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Track safe module import from an import declaration.
140
+ */
141
+ function trackSafeImport(node, state) {
142
+ state.safeImportNode = node;
143
+ for (const spec of node.specifiers) {
144
+ if (spec.type === 'ImportSpecifier' && spec.imported.name === SAFE_OBJECT) {
145
+ state.hasSafePathImport = true;
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Is `name` resolvable from `node`'s scope outward — a parameter, a local, an
152
+ * import, or a configured global?
153
+ *
154
+ * Used only to decide whether a bare `join(...)` with no `node:path` import is
155
+ * OUR `join` or somebody else's. `import { join } from 'lodash'` binds the name
156
+ * and is not our business; an unbound `join` is a ReferenceError waiting to
157
+ * happen, and — see `classifyCall` — is exactly what a half-applied autofix
158
+ * leaves behind.
159
+ */
160
+ function isIdentifierBound(sourceCode, node, name) {
161
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
162
+ if (scope.variables.some((variable) => variable.name === name)) {
163
+ return true;
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * If this is the call THIS rule's fixer writes — `safePath.<fn>(…)` for one of
171
+ * the configured functions — return that function's name.
172
+ *
173
+ * The evidence that this rule migrated something in this file, and the reason it
174
+ * is not enough to ask whether `safePath` is merely in scope. Read by BOTH legs
175
+ * that would otherwise mistake a file-wide fact for a claim about one function:
176
+ * the dead-import leg, and the bare-call repair leg in `classifyCall`.
177
+ * Matched whether or not `safePath` is bound: an ORPHANED `safePath.join(…)` —
178
+ * the repair leg's own subject — is still this fixer's handiwork.
179
+ *
180
+ * @param {object} node - A `CallExpression`.
181
+ * @param {Map<string, object>} byFn - Per-function state, keyed by name.
182
+ * @returns {string | undefined} The wrapped function's name, or undefined.
183
+ */
184
+ function safeReplacementCallee(node, byFn) {
185
+ const { callee } = node;
186
+ const isSafeMember =
187
+ callee.type === 'MemberExpression' &&
188
+ callee.object.type === 'Identifier' &&
189
+ callee.object.name === SAFE_OBJECT &&
190
+ callee.property.type === 'Identifier' &&
191
+ byFn.has(callee.property.name);
192
+ return isSafeMember ? callee.property.name : undefined;
193
+ }
194
+
195
+ /**
196
+ * Classify a bare `fn(...)` call — a NAMED import, a REPAIR candidate, or not ours.
197
+ *
198
+ * REPAIR LEG. Keying detection on "did I see the import?" made this rule stop
199
+ * reporting the moment a fix removed the specifier, so a partial `--fix`
200
+ * reached a stable fixpoint over source that no longer compiles and exited
201
+ * clean.
202
+ *
203
+ * An unbound `join` is NOT reliably our `join`: ESLint scope analysis does not
204
+ * bind `declare global { function join() }`, and it cannot see an ambient
205
+ * global from a `globals.d.ts`, an `@types` package, or a bundler — `resolve`
206
+ * and `relative` are entirely plausible as those. So the leg needs a second
207
+ * condition, and `safePathBoundInSource` alone was the wrong one: it is a fact
208
+ * about the FILE, not about `fn`, and a SIBLING function in this same table
209
+ * hands it over for free. The `resolve` leg rewrites a `path.resolve(...)` and
210
+ * imports `safePath`; on the next `--fix` pass the `join` leg sees a file with
211
+ * `safePath` bound and an ambient-global `join(...)` that predates any of this
212
+ * pack's involvement, and rewrites it to a different function. Measured with
213
+ * both legs enabled over one file.
214
+ *
215
+ * So this is only a CANDIDATE. `Program:exit` admits it once the file has been
216
+ * seen to contain a `safePath.<fn>(...)` call — the same positive evidence the
217
+ * dead-import leg reads, and the only in-file signal that THIS function is
218
+ * mid-migration HERE. Every genuine strand carries it: `removeSpecifier` only
219
+ * ever ships inside the same report's fix as the callee rewrite that consumed
220
+ * the specifier, and ESLint merges a report's fixes into one all-or-nothing
221
+ * range — so a file cannot lose the `node:path` specifier without gaining a
222
+ * `safePath.<fn>(` call.
223
+ *
224
+ * What this gives up: a file a HUMAN half-migrated by hand — import deleted,
225
+ * not one call rewritten — is no longer finished by `--fix`. That file is a
226
+ * loud `no-undef`/`tsc` error rather than a silent one, and it was never this
227
+ * pack's doing. Silently redirecting a live call to another function is.
228
+ */
229
+ function classifyBareCall(node, fnState, state, sourceCode) {
230
+ if (fnState.namedImportSpec) {
231
+ return { fn: fnState.fn, isNamed: true };
232
+ }
233
+ if (state.safePathBoundInSource && !isIdentifierBound(sourceCode, node.callee, fnState.fn)) {
234
+ return { fn: fnState.fn, isNamed: false, isRepair: true };
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * Check if a call expression is an unsafe path function call.
241
+ *
242
+ * Returns `{ fn, isNamed }` — or `{ fn, importOnly: true }` for a call that is
243
+ * already correct and merely missing its import — or `{ fn, isRepair: true }`
244
+ * for a bare call that is ours only if the file also shows THIS function
245
+ * mid-migration, which `Program:exit` decides once the whole file has been seen
246
+ * — or null if not a match.
247
+ */
248
+ function classifyCall(node, state, sourceCode) {
249
+ const { callee } = node;
250
+
251
+ // Direct call: join(...)
252
+ if (callee.type === 'Identifier') {
253
+ const fnState = state.byFn.get(callee.name);
254
+ return fnState ? classifyBareCall(node, fnState, state, sourceCode) : null;
255
+ }
256
+
257
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier' || callee.object.type !== 'Identifier') {
258
+ return null;
259
+ }
260
+ const fn = callee.property.name;
261
+ if (!state.byFn.has(fn)) {
262
+ return null;
263
+ }
264
+
265
+ // Namespace call: path.join(...)
266
+ if (callee.object.name === state.defaultImportName) {
267
+ return { fn, isNamed: false };
268
+ }
269
+
270
+ // REPAIR LEG, the other half: `safePath.join(...)` with no `safePath` in
271
+ // scope. This is what a partially-applied fix leaves — and without it, that
272
+ // state is PERMANENT rather than transient.
273
+ //
274
+ // ESLint runs `fix()` for a problem BEFORE the `eslint-disable` filter
275
+ // discards it, so a suppressed report on the first call site consumes the
276
+ // once-per-file import edit and then throws it away. Every other call is
277
+ // rewritten to `safePath.join`, nothing imports `safePath`, and no report
278
+ // survives to carry the import on any later pass. Recognising the orphaned
279
+ // call is what closes that loop; it costs one extra pass, and only in a file
280
+ // that is already broken.
281
+ if (callee.object.name === SAFE_OBJECT && !isIdentifierBound(sourceCode, callee.object, SAFE_OBJECT)) {
282
+ return { fn, importOnly: true };
283
+ }
284
+
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Build auto-fix for an unsafe path function call.
290
+ *
291
+ * ## Why the import edits are emitted at most ONCE per file
292
+ *
293
+ * ESLint merges the fixes one `fix()` yields into a SINGLE range spanning
294
+ * `min..max`, and applies only non-overlapping ranges per pass. A fix that
295
+ * touches both the import and its own call site therefore spans everything in
296
+ * between — so N such reports produce N nested ranges, ESLint keeps the
297
+ * shortest and DISCARDS THE REST.
298
+ *
299
+ * That is not an edge case, it is every file with more than one call site. The
300
+ * import edit landed, the other calls did not, and (before `classifyCall` grew
301
+ * its bare-call leg) the next pass could no longer see them because the
302
+ * specifier it keyed on was gone. `--fix` reached a stable fixpoint over source
303
+ * that does not compile and exited clean. An adopter measured 146 files left
304
+ * with a dangling reference across one sweep — worst single file, 75 call sites.
305
+ *
306
+ * So: the shared edits belong to the first report, and every later report emits
307
+ * a fix LOCAL to its own callee. Nothing overlaps, and one pass fixes the file.
308
+ * `no-manual-path-normalize.cjs` carries the same guard for the same reason.
309
+ *
310
+ * Only the FIRST report's fix is self-sufficient, and that is load-bearing:
311
+ * applying a later one ALONE — an editor's "fix this problem", or an
312
+ * `eslint-disable` on the first call site — rewrites the call without adding
313
+ * the import. ESLint runs `fix()` before the disable filter, so a suppressed
314
+ * report consumes the once-per-file edit and then discards it.
315
+ *
316
+ * That state is recoverable rather than permanent ONLY because `classifyCall`
317
+ * has a repair leg for an orphaned `safePath.join(...)`. Without it the file
318
+ * stays broken through every subsequent `--fix`, because no report is left to
319
+ * carry the import — measured, not reasoned about. An earlier draft of this
320
+ * comment asserted the recovery came free from `hasSafePathImport` being seeded
321
+ * from scope; that was wrong, and an adversarial run produced the stable broken
322
+ * fixpoint to prove it.
323
+ *
324
+ * The shared edits still cannot be hoisted onto their own report: removing
325
+ * `join` from the import while a suppressed `join(...)` call survives is the
326
+ * same broken output reached a different way. `exemptFiles` opts a whole file
327
+ * out.
328
+ */
329
+ function importSafePath(fixer, sourceCode, state, anchorImportNode) {
330
+ if (state.safeImportNode) {
331
+ const lastSpec = state.safeImportNode.specifiers.at(-1);
332
+ return fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`);
333
+ }
334
+ const targetNode = anchorImportNode || sourceCode.ast.body[0];
335
+ const declaration = `import { ${SAFE_OBJECT} } from '${state.safeModule}';`;
336
+ // Land the new import next to the imports, not after arbitrary code. A file
337
+ // reported only through a repair leg may have no path import at all, and
338
+ // `insertTextAfter(body[0])` would push the declaration below the statement
339
+ // that needs it — legal, since imports hoist, but it reads as though the
340
+ // fixer lost track of the file.
341
+ return targetNode.type === 'ImportDeclaration'
342
+ ? fixer.insertTextAfter(targetNode, `\n${declaration}`)
343
+ : insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`);
344
+ }
345
+
346
+ function buildFix(fixer, node, classification, sourceCode, state) {
347
+ const fnState = state.byFn.get(classification.fn);
348
+
349
+ // REPAIR: an orphaned `safePath.join(...)` is already the call we want, and
350
+ // the only thing missing is the import that a discarded report was carrying.
351
+ //
352
+ // This deliberately ignores `state.hasSafePathImport`. That flag is mutated
353
+ // inside `fix()`, and ESLint runs `fix()` for a SUPPRESSED problem before the
354
+ // disable filter throws it away — so on every pass the suppressed report
355
+ // spends the flag first and the repair emits nothing. The file then never
356
+ // recovers, which is precisely the stable broken fixpoint this leg exists to
357
+ // break. The gate that makes ignoring the flag safe is immutable: this
358
+ // classification is only reached when `safePath` is unbound in the SOURCE.
359
+ //
360
+ // Several orphaned calls yield the identical insert at the identical anchor,
361
+ // so ESLint applies one and drops the rest as overlapping — which is the
362
+ // desired outcome, not a hazard.
363
+ if (classification.importOnly) {
364
+ return [importSafePath(fixer, sourceCode, state, fnState.namedImportNode)];
365
+ }
366
+
367
+ const fixes = [fixer.replaceText(node.callee, `${SAFE_OBJECT}.${classification.fn}`)];
368
+
369
+ if (!state.hasSafePathImport) {
370
+ fixes.push(importSafePath(fixer, sourceCode, state, fnState.namedImportNode));
371
+ state.hasSafePathImport = true;
372
+ }
373
+
374
+ if (
375
+ classification.isNamed &&
376
+ fnState.namedImportNode &&
377
+ !fnState.namedImportRemoved &&
378
+ !isReExported(sourceCode, classification.fn)
379
+ ) {
380
+ fixes.push(...removeSpecifier(fixer, sourceCode, fnState.namedImportNode, fnState.namedImportSpec));
381
+ fnState.namedImportRemoved = true;
382
+ }
383
+
384
+ return fixes;
385
+ }
386
+
387
+ /** Per-function state: the specifier THIS function owns, and its migration evidence. */
388
+ function createFunctionState(fn) {
389
+ return {
390
+ fn,
391
+ namedImportSpec: null,
392
+ namedImportNode: null,
393
+ // Guards a SHARED edit against being emitted by more than one report — see
394
+ // `buildFix` for what ESLint does with the overlap.
395
+ namedImportRemoved: false,
396
+ // Set from the SOURCE as traversal walks it, and read only at
397
+ // `Program:exit` — so it is a fact about the text being linted, not about
398
+ // what a `fix()` intends to write. See `dead-import.cjs`, and the repair
399
+ // leg in `classifyBareCall`, which gates on the same flag.
400
+ safeReplacementCalled: false,
401
+ // Bare unbound calls awaiting that evidence. Held rather than reported,
402
+ // because a candidate can precede the migrated call that vouches for it.
403
+ deferredRepairs: [],
404
+ };
405
+ }
406
+
407
+ /** Options schema: `exemptFiles` and `safeModule` from the shared schema, plus the function table. */
408
+ const OPTIONS_SCHEMA = Object.freeze({
409
+ ...EXEMPT_AND_SAFE_MODULE_SCHEMA,
410
+ properties: {
411
+ ...EXEMPT_AND_SAFE_MODULE_SCHEMA.properties,
412
+ functions: {
413
+ type: 'array',
414
+ items: { enum: WRAPPED_FUNCTIONS },
415
+ minItems: 1,
416
+ uniqueItems: true,
417
+ },
418
+ },
419
+ });
420
+
421
+ module.exports = {
422
+ meta: {
423
+ type: 'problem',
424
+ docs: {
425
+ description: 'Enforce safePath.join() / .resolve() / .relative() instead of the raw node:path functions',
426
+ category: 'Path handling',
427
+ bans: '`path.join()`, `path.resolve()`, `path.relative()`',
428
+ useInstead: '`safePath.join()` / `.resolve()` / `.relative()`',
429
+ subpath: '/path',
430
+ // `warn`, not `error`: by far the highest-churn rule in the pack — it
431
+ // fires on every raw `node:path` call in an existing codebase. Measured
432
+ // on a 4,670-file adopter tree: 3,963 + 372 + 1 findings, every one of
433
+ // them autofixable. `warn` lets a project run `--fix` and burn the list
434
+ // down incrementally instead of blocking CI on day one; raise it to
435
+ // `error` once the backlog is clear, which is what VAT itself does. The
436
+ // criterion is MIGRATION VOLUME, not how real the finding is.
437
+ recommended: true,
438
+ recommendedSeverity: 'warn',
439
+ },
440
+ fixable: 'code',
441
+ schema: [OPTIONS_SCHEMA],
442
+ messages: {
443
+ noUnsafePathFn:
444
+ 'Use safePath.{{fn}}() from {{safeModule}} instead of path.{{fn}}(). ' +
445
+ 'path.{{fn}}() returns backslashes on Windows, causing Map key mismatches and path comparison bugs.',
446
+ [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
447
+ [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
448
+ },
449
+ },
450
+
451
+ create(context) {
452
+ if (exemptMatcherFor(context)(context.getFilename())) {
453
+ // Still surface a malformed exemption list: the file we are standing in
454
+ // may be exempt only BECAUSE the entry is unanchored.
455
+ return {
456
+ Program(node) {
457
+ reportUnanchoredExemptEntries(context, node);
458
+ },
459
+ };
460
+ }
461
+
462
+ const sourceCode = context.getSourceCode();
463
+ const functions = context.options[0]?.functions ?? WRAPPED_FUNCTIONS;
464
+ const state = {
465
+ // Resolved per invocation — the option belongs to the consuming repo,
466
+ // which may point different rules at different re-export entries.
467
+ safeModule: resolveSafeModule(context, SAFE_PATH_MODULE),
468
+ byFn: new Map(functions.map((fn) => [fn, createFunctionState(fn)])),
469
+ defaultImportName: null,
470
+ // Seeded from SCOPE, not from "did I see an import from SAFE_MODULE?".
471
+ // A file already importing `safePath` from the barrel needs the call
472
+ // rewritten but must NOT gain a second binding of the same name.
473
+ hasSafePathImport: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
474
+ // The SAME question, answered once and never mutated. `hasSafePathImport`
475
+ // flips to true the moment a fix inserts the import, and gating the
476
+ // repair leg on a flag that the first report can flip would arm it for
477
+ // the rest of THIS pass — re-admitting the ambient-global false positive
478
+ // in any file that also has a `path.join()` to fix.
479
+ safePathBoundInSource: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
480
+ safeImportNode: null,
481
+ // EVERY path-module declaration, not just the ones carrying a wrapped
482
+ // function. A file's dead binding is `import path from 'node:path'`,
483
+ // which `trackPathImport` only ever recorded as a NAME. See `dead-import.cjs`.
484
+ pathImportNodes: [],
485
+ };
486
+
487
+ const report = (node, classification) => {
488
+ context.report({
489
+ node,
490
+ messageId: 'noUnsafePathFn',
491
+ // The module name reaches the message through `{{safeModule}}` rather
492
+ // than being spelled out in a per-function string, so the advice cannot
493
+ // drift from where the fixer actually writes the import.
494
+ data: { fn: classification.fn, safeModule: state.safeModule },
495
+ fix(fixer) {
496
+ return buildFix(fixer, node, classification, sourceCode, state);
497
+ },
498
+ });
499
+ };
500
+
501
+ return {
502
+ Program(node) {
503
+ reportUnanchoredExemptEntries(context, node);
504
+ },
505
+
506
+ 'Program:exit'() {
507
+ // Holding these back cannot change any FIX, only when it is computed.
508
+ // A repair candidate is classified only when `safePath` is bound in the
509
+ // source, so `hasSafePathImport` starts true and `namedImportSpec` is
510
+ // null: the report carries no import insert and no specifier removal,
511
+ // and therefore nothing shared that an earlier report could have spent
512
+ // first. (`importOnly` is the mirror case — it requires `safePath`
513
+ // UNBOUND — so the two can never arise in the same file.)
514
+ let anyReplacementCalled = false;
515
+ for (const fnState of state.byFn.values()) {
516
+ if (!fnState.safeReplacementCalled) {
517
+ continue;
518
+ }
519
+ anyReplacementCalled = true;
520
+ for (const candidate of fnState.deferredRepairs) {
521
+ report(candidate, { fn: fnState.fn, isNamed: false });
522
+ }
523
+ }
524
+ reportDeadUnsafeImports(
525
+ context,
526
+ sourceCode,
527
+ state.pathImportNodes,
528
+ state.safePathBoundInSource,
529
+ anyReplacementCalled,
530
+ );
531
+ },
532
+
533
+ ImportDeclaration(node) {
534
+ if (PATH_MODULES.has(node.source.value)) {
535
+ state.pathImportNodes.push(node);
536
+ trackPathImport(node, state);
537
+ }
538
+ if (node.source.value === state.safeModule) {
539
+ trackSafeImport(node, state);
540
+ }
541
+ },
542
+
543
+ CallExpression(node) {
544
+ const replaced = safeReplacementCallee(node, state.byFn);
545
+ if (replaced !== undefined) {
546
+ state.byFn.get(replaced).safeReplacementCalled = true;
547
+ }
548
+
549
+ const classification = classifyCall(node, state, sourceCode);
550
+ if (!classification) {
551
+ return;
552
+ }
553
+
554
+ if (classification.isRepair) {
555
+ state.byFn.get(classification.fn).deferredRepairs.push(node);
556
+ return;
557
+ }
558
+
559
+ report(node, classification);
560
+ },
561
+ };
562
+ },
563
+ };