@vibe-agent-toolkit/utils 0.1.42 → 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 (279) hide show
  1. package/README.md +81 -19
  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 +214 -24
  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 +628 -38
  44. package/dist/fs-utils.d.ts.map +1 -1
  45. package/dist/fs-utils.js +770 -71
  46. package/dist/fs-utils.js.map +1 -1
  47. package/dist/fs.d.ts +5 -1
  48. package/dist/fs.d.ts.map +1 -1
  49. package/dist/fs.js +9 -1
  50. package/dist/fs.js.map +1 -1
  51. package/dist/git-root-cache.d.ts +44 -0
  52. package/dist/git-root-cache.d.ts.map +1 -0
  53. package/dist/git-root-cache.js +68 -0
  54. package/dist/git-root-cache.js.map +1 -0
  55. package/dist/git-run.d.ts +167 -0
  56. package/dist/git-run.d.ts.map +1 -0
  57. package/dist/git-run.js +154 -0
  58. package/dist/git-run.js.map +1 -0
  59. package/dist/git-snapshot.d.ts +199 -0
  60. package/dist/git-snapshot.d.ts.map +1 -0
  61. package/dist/git-snapshot.js +250 -0
  62. package/dist/git-snapshot.js.map +1 -0
  63. package/dist/git-tracker.d.ts +166 -3
  64. package/dist/git-tracker.d.ts.map +1 -1
  65. package/dist/git-tracker.js +250 -22
  66. package/dist/git-tracker.js.map +1 -1
  67. package/dist/git-utils.d.ts +95 -1
  68. package/dist/git-utils.d.ts.map +1 -1
  69. package/dist/git-utils.js +227 -81
  70. package/dist/git-utils.js.map +1 -1
  71. package/dist/git.d.ts +13 -4
  72. package/dist/git.d.ts.map +1 -1
  73. package/dist/git.js +12 -4
  74. package/dist/git.js.map +1 -1
  75. package/dist/gitignore-checker.d.ts.map +1 -1
  76. package/dist/gitignore-checker.js +15 -6
  77. package/dist/gitignore-checker.js.map +1 -1
  78. package/dist/glob/glob-pattern.js +5 -5
  79. package/dist/glob/glob-pattern.js.map +1 -1
  80. package/dist/index.d.ts +33 -19
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +113 -33
  83. package/dist/index.js.map +1 -1
  84. package/dist/listing-refusal.d.ts +112 -0
  85. package/dist/listing-refusal.d.ts.map +1 -0
  86. package/dist/listing-refusal.js +105 -0
  87. package/dist/listing-refusal.js.map +1 -0
  88. package/dist/numeric-args.d.ts +24 -0
  89. package/dist/numeric-args.d.ts.map +1 -0
  90. package/dist/numeric-args.js +37 -0
  91. package/dist/numeric-args.js.map +1 -0
  92. package/dist/path-containment.d.ts +64 -0
  93. package/dist/path-containment.d.ts.map +1 -0
  94. package/dist/path-containment.js +111 -0
  95. package/dist/path-containment.js.map +1 -0
  96. package/dist/path-core.d.ts +143 -18
  97. package/dist/path-core.d.ts.map +1 -1
  98. package/dist/path-core.js +171 -23
  99. package/dist/path-core.js.map +1 -1
  100. package/dist/path-utils.d.ts.map +1 -1
  101. package/dist/path-utils.js +42 -47
  102. package/dist/path-utils.js.map +1 -1
  103. package/dist/path.d.ts +1 -1
  104. package/dist/path.d.ts.map +1 -1
  105. package/dist/path.js +1 -1
  106. package/dist/path.js.map +1 -1
  107. package/dist/process.d.ts +7 -0
  108. package/dist/process.d.ts.map +1 -1
  109. package/dist/process.js +7 -0
  110. package/dist/process.js.map +1 -1
  111. package/dist/project-utils.d.ts +7 -1
  112. package/dist/project-utils.d.ts.map +1 -1
  113. package/dist/project-utils.js +24 -10
  114. package/dist/project-utils.js.map +1 -1
  115. package/dist/project.d.ts +7 -5
  116. package/dist/project.d.ts.map +1 -1
  117. package/dist/project.js +7 -5
  118. package/dist/project.js.map +1 -1
  119. package/dist/safe-exec.d.ts +40 -11
  120. package/dist/safe-exec.d.ts.map +1 -1
  121. package/dist/safe-exec.js +111 -25
  122. package/dist/safe-exec.js.map +1 -1
  123. package/dist/skill-targets.d.ts +2 -2
  124. package/dist/skill-targets.js +2 -2
  125. package/dist/skill-test/auth-resolver.d.ts +10 -2
  126. package/dist/skill-test/auth-resolver.d.ts.map +1 -1
  127. package/dist/skill-test/auth-resolver.js +26 -11
  128. package/dist/skill-test/auth-resolver.js.map +1 -1
  129. package/dist/skill-test/index.d.ts +1 -1
  130. package/dist/skill-test/index.d.ts.map +1 -1
  131. package/dist/skill-test/index.js +1 -1
  132. package/dist/skill-test/index.js.map +1 -1
  133. package/dist/skill-test/spawn-claude.d.ts +30 -1
  134. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  135. package/dist/skill-test/spawn-claude.js +71 -5
  136. package/dist/skill-test/spawn-claude.js.map +1 -1
  137. package/dist/skill-test/transcript.d.ts +41 -2
  138. package/dist/skill-test/transcript.d.ts.map +1 -1
  139. package/dist/skill-test/transcript.js +55 -10
  140. package/dist/skill-test/transcript.js.map +1 -1
  141. package/dist/spawn-hardened.js +0 -1
  142. package/dist/spawn-hardened.js.map +1 -1
  143. package/dist/stdio-blocking.js +13 -14
  144. package/dist/stdio-blocking.js.map +1 -1
  145. package/dist/test-helpers.d.ts +130 -79
  146. package/dist/test-helpers.d.ts.map +1 -1
  147. package/dist/test-helpers.js +229 -124
  148. package/dist/test-helpers.js.map +1 -1
  149. package/dist/testing/executables.d.ts +32 -0
  150. package/dist/testing/executables.d.ts.map +1 -0
  151. package/dist/testing/executables.js +70 -0
  152. package/dist/testing/executables.js.map +1 -0
  153. package/dist/testing/hostile-tree.d.ts +79 -0
  154. package/dist/testing/hostile-tree.d.ts.map +1 -0
  155. package/dist/testing/hostile-tree.js +164 -0
  156. package/dist/testing/hostile-tree.js.map +1 -0
  157. package/dist/testing/platform-gates.d.ts +31 -0
  158. package/dist/testing/platform-gates.d.ts.map +1 -0
  159. package/dist/testing/platform-gates.js +31 -0
  160. package/dist/testing/platform-gates.js.map +1 -0
  161. package/dist/testing/temp-dir.d.ts +255 -0
  162. package/dist/testing/temp-dir.d.ts.map +1 -0
  163. package/dist/testing/temp-dir.js +363 -0
  164. package/dist/testing/temp-dir.js.map +1 -0
  165. package/dist/testing.d.ts +94 -3
  166. package/dist/testing.d.ts.map +1 -1
  167. package/dist/testing.js +119 -3
  168. package/dist/testing.js.map +1 -1
  169. package/dist/text-content.d.ts +197 -0
  170. package/dist/text-content.d.ts.map +1 -0
  171. package/dist/text-content.js +354 -0
  172. package/dist/text-content.js.map +1 -0
  173. package/dist/text-file.d.ts +45 -0
  174. package/dist/text-file.d.ts.map +1 -0
  175. package/dist/text-file.js +51 -0
  176. package/dist/text-file.js.map +1 -0
  177. package/dist/text.d.ts +16 -0
  178. package/dist/text.d.ts.map +1 -0
  179. package/dist/text.js +16 -0
  180. package/dist/text.js.map +1 -0
  181. package/dist/timing-dump.d.ts +136 -0
  182. package/dist/timing-dump.d.ts.map +1 -0
  183. package/dist/timing-dump.js +229 -0
  184. package/dist/timing-dump.js.map +1 -0
  185. package/dist/windows-shell.d.ts +0 -1
  186. package/dist/windows-shell.d.ts.map +1 -1
  187. package/dist/windows-shell.js +0 -1
  188. package/dist/windows-shell.js.map +1 -1
  189. package/dist/zod-introspection.js +2 -2
  190. package/dist/zod-introspection.js.map +1 -1
  191. package/eslint/README.md +224 -50
  192. package/eslint/index.cjs +111 -97
  193. package/eslint/index.d.cts +16 -6
  194. package/eslint/rules/callee-name.cjs +23 -0
  195. package/eslint/rules/commands-import-boundary.cjs +148 -0
  196. package/eslint/rules/dead-import.cjs +62 -12
  197. package/eslint/rules/dirent-type-needs-symlink-check.cjs +282 -0
  198. package/eslint/rules/eslint-rule-factory.cjs +21 -3
  199. package/eslint/rules/exempt-path-matcher.cjs +2 -1
  200. package/eslint/rules/explicit-zod-strictness.cjs +120 -0
  201. package/eslint/rules/no-bare-dynamic-import-path.cjs +5 -1
  202. package/eslint/rules/no-bare-executable-spawn.cjs +70 -0
  203. package/eslint/rules/no-bare-symlink-in-tests.cjs +213 -0
  204. package/eslint/rules/no-blind-catch.cjs +171 -0
  205. package/eslint/rules/no-child-process-execSync.cjs +8 -0
  206. package/eslint/rules/no-decaying-referent.cjs +217 -0
  207. package/eslint/rules/no-dotdot-containment.cjs +160 -0
  208. package/eslint/rules/no-file-url-string-concat.cjs +4 -1
  209. package/eslint/rules/no-fragile-entrypoint-guard.cjs +228 -0
  210. package/eslint/rules/no-fs-mkdirSync.cjs +8 -0
  211. package/eslint/rules/no-fs-promises-cp.cjs +7 -0
  212. package/eslint/rules/no-fs-realpathSync.cjs +8 -0
  213. package/eslint/rules/no-hardcoded-path-split.cjs +29 -18
  214. package/eslint/rules/no-io-in-unit-tier.cjs +129 -0
  215. package/eslint/rules/no-literal-process-exit.cjs +129 -0
  216. package/eslint/rules/no-manual-path-normalize.cjs +146 -96
  217. package/eslint/rules/no-os-tmpdir.cjs +8 -0
  218. package/eslint/rules/no-path-operations-in-comparisons.cjs +7 -2
  219. package/eslint/rules/no-path-sep-in-strings.cjs +7 -2
  220. package/eslint/rules/no-path-startswith.cjs +7 -2
  221. package/eslint/rules/no-process-exit-in-phase.cjs +127 -0
  222. package/eslint/rules/no-raw-node-path.cjs +563 -0
  223. package/eslint/rules/no-raw-text-decode.cjs +305 -0
  224. package/eslint/rules/no-registry-count-pin.cjs +255 -0
  225. package/eslint/rules/no-self-package-import.cjs +168 -0
  226. package/eslint/rules/no-test-scoped-functions.cjs +10 -3
  227. package/eslint/rules/no-unix-shell-commands.cjs +6 -2
  228. package/eslint/rules/no-unsafe-root-join.cjs +23 -2
  229. package/eslint/rules/no-url-pathname-for-fs.cjs +5 -1
  230. package/eslint/rules/no-version-literal.cjs +151 -0
  231. package/eslint/rules/prefer-startswith-over-regex.cjs +33 -1
  232. package/eslint/rules/require-justified-skip.cjs +8 -2
  233. package/eslint/rules/safe-import.cjs +1 -1
  234. package/package.json +16 -16
  235. package/dist/link-auth/build-headers.d.ts +0 -34
  236. package/dist/link-auth/build-headers.d.ts.map +0 -1
  237. package/dist/link-auth/build-headers.js +0 -58
  238. package/dist/link-auth/build-headers.js.map +0 -1
  239. package/dist/link-auth/expand-macro.d.ts +0 -38
  240. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  241. package/dist/link-auth/expand-macro.js +0 -133
  242. package/dist/link-auth/expand-macro.js.map +0 -1
  243. package/dist/link-auth/macros.yaml +0 -50
  244. package/dist/link-auth/resolve-token.d.ts +0 -83
  245. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  246. package/dist/link-auth/resolve-token.js +0 -101
  247. package/dist/link-auth/resolve-token.js.map +0 -1
  248. package/dist/link-auth/resolve.d.ts +0 -102
  249. package/dist/link-auth/resolve.d.ts.map +0 -1
  250. package/dist/link-auth/resolve.js +0 -66
  251. package/dist/link-auth/resolve.js.map +0 -1
  252. package/dist/link-auth/rewrite.d.ts +0 -52
  253. package/dist/link-auth/rewrite.d.ts.map +0 -1
  254. package/dist/link-auth/rewrite.js +0 -102
  255. package/dist/link-auth/rewrite.js.map +0 -1
  256. package/dist/link-auth/select-provider.d.ts +0 -30
  257. package/dist/link-auth/select-provider.d.ts.map +0 -1
  258. package/dist/link-auth/select-provider.js +0 -55
  259. package/dist/link-auth/select-provider.js.map +0 -1
  260. package/dist/link-auth/template.d.ts +0 -40
  261. package/dist/link-auth/template.d.ts.map +0 -1
  262. package/dist/link-auth/template.js +0 -89
  263. package/dist/link-auth/template.js.map +0 -1
  264. package/dist/link-auth/transforms.d.ts +0 -46
  265. package/dist/link-auth/transforms.d.ts.map +0 -1
  266. package/dist/link-auth/transforms.js +0 -52
  267. package/dist/link-auth/transforms.js.map +0 -1
  268. package/dist/template-entry.d.ts +0 -10
  269. package/dist/template-entry.d.ts.map +0 -1
  270. package/dist/template-entry.js +0 -10
  271. package/dist/template-entry.js.map +0 -1
  272. package/dist/template.d.ts +0 -7
  273. package/dist/template.d.ts.map +0 -1
  274. package/dist/template.js +0 -18
  275. package/dist/template.js.map +0 -1
  276. package/eslint/rules/no-path-join.cjs +0 -14
  277. package/eslint/rules/no-path-relative.cjs +0 -14
  278. package/eslint/rules/no-path-resolve.cjs +0 -14
  279. package/eslint/rules/path-function-rule-factory.cjs +0 -417
@@ -1,14 +0,0 @@
1
- /**
2
- * ESLint rule: no-path-join
3
- *
4
- * Bans path.join() from node:path. Use safePath.join() from `@vibe-agent-toolkit/utils/path`.
5
- * safePath.join() wraps path.join() + toForwardSlash() to prevent Windows backslash bugs.
6
- */
7
- const factory = require('./path-function-rule-factory.cjs');
8
-
9
- module.exports = factory({
10
- unsafeFn: 'join',
11
- message:
12
- 'Use safePath.join() from {{safeModule}} instead of path.join(). ' +
13
- 'path.join() returns backslashes on Windows, causing Map key mismatches and path comparison bugs.',
14
- });
@@ -1,14 +0,0 @@
1
- /**
2
- * ESLint rule: no-path-relative
3
- *
4
- * Bans path.relative() from node:path. Use safePath.relative() from `@vibe-agent-toolkit/utils/path`.
5
- * safePath.relative() wraps path.relative() + toForwardSlash() to prevent Windows backslash bugs.
6
- */
7
- const factory = require('./path-function-rule-factory.cjs');
8
-
9
- module.exports = factory({
10
- unsafeFn: 'relative',
11
- message:
12
- 'Use safePath.relative() from {{safeModule}} instead of path.relative(). ' +
13
- 'path.relative() returns backslashes on Windows, causing Map key mismatches and path comparison bugs.',
14
- });
@@ -1,14 +0,0 @@
1
- /**
2
- * ESLint rule: no-path-resolve
3
- *
4
- * Bans path.resolve() from node:path. Use safePath.resolve() from `@vibe-agent-toolkit/utils/path`.
5
- * safePath.resolve() wraps path.resolve() + toForwardSlash() to prevent Windows backslash bugs.
6
- */
7
- const factory = require('./path-function-rule-factory.cjs');
8
-
9
- module.exports = factory({
10
- unsafeFn: 'resolve',
11
- message:
12
- 'Use safePath.resolve() from {{safeModule}} instead of path.resolve(). ' +
13
- 'path.resolve() returns backslashes on Windows, causing Map key mismatches and path comparison bugs.',
14
- });
@@ -1,417 +0,0 @@
1
- /**
2
- * ESLint Rule Factory for banning specific path functions from node:path
3
- *
4
- * Handles both import styles:
5
- * - Named: import { join } from 'node:path' → join(...)
6
- * - Default/namespace: import path from 'node:path' → path.join(...)
7
- *
8
- * Auto-fixes to safePath.fn() from `@vibe-agent-toolkit/utils/path` — the narrow
9
- * subpath that owns `safePath`, NOT the barrel. See `safe-import.cjs`.
10
- */
11
-
12
- const {
13
- DEAD_UNSAFE_IMPORT,
14
- DEAD_UNSAFE_IMPORT_MESSAGE,
15
- reportDeadUnsafeImports,
16
- } = require('./dead-import.cjs');
17
- const {
18
- UNANCHORED_EXEMPT_FILE,
19
- UNANCHORED_EXEMPT_MESSAGE,
20
- createConfigurableExemptPathMatcher,
21
- reportUnanchoredExemptEntries,
22
- } = require('./exempt-path-matcher.cjs');
23
- const {
24
- EXEMPT_AND_SAFE_MODULE_SCHEMA,
25
- SAFE_PATH_MODULE,
26
- insertAboveWithComments,
27
- isNameAlreadyBound,
28
- resolveSafeModule,
29
- } = require('./safe-import.cjs');
30
-
31
- const PATH_MODULES = new Set(['node:path', 'path']);
32
- const SAFE_OBJECT = 'safePath';
33
-
34
- /**
35
- * The files allowed to call raw `node:path` functions are whichever ones the
36
- * CONSUMING repo says implement (or assert) its `safePath` wrappers — declared
37
- * per-rule as `{ exemptFiles: [...] }`, matched at a path-segment boundary (see
38
- * `exempt-path-matcher.cjs` for why these are not substrings).
39
- *
40
- * This list used to be hardcoded to VAT's own `packages/utils/src/path-core.ts`
41
- * et al. Those paths are meaningless in an adopter's tree and actively harmful
42
- * as a default — a same-named file at the same repo-relative path would inherit
43
- * an exemption it never declared. Default: nothing is exempt.
44
- */
45
- const exemptMatcherFor = createConfigurableExemptPathMatcher();
46
-
47
- /**
48
- * Remove a named import specifier, handling comma cleanup.
49
- */
50
- function removeSpecifier(fixer, sourceCode, importNode, spec) {
51
- if (importNode.specifiers.length === 1) {
52
- return [fixer.remove(importNode)];
53
- }
54
- const comma = sourceCode.getTokenAfter(spec);
55
- if (comma?.value === ',') {
56
- return [fixer.removeRange([spec.range[0], comma.range[1]])];
57
- }
58
- const commaBefore = sourceCode.getTokenBefore(spec);
59
- if (commaBefore?.value === ',') {
60
- return [fixer.removeRange([commaBefore.range[0], spec.range[1]])];
61
- }
62
- return [fixer.remove(spec)];
63
- }
64
-
65
- /**
66
- * Track path module specifiers from an import declaration.
67
- *
68
- * Two specifier shapes are deliberately NOT tracked, because tracking them is
69
- * what let the fixer delete them:
70
- *
71
- * - **Type-only** (`import { type join, … }` / `import type { join }`). The
72
- * binding exists only for the type checker; there is no call to rewrite, and
73
- * removing the specifier silently breaks every `typeof join` that referenced
74
- * it. `no-undef` cannot see the damage — it is a TYPE reference.
75
- * - **Aliased** (`import { join as pathJoin }`). The rule never reported
76
- * `pathJoin(...)` in the first place — `classifyCall` matches on the callee's
77
- * name — so tracking the specifier bought nothing and cost the whole import:
78
- * an unrelated unbound `join(` elsewhere in the file made the fixer remove
79
- * the alias, breaking every working `pathJoin` call site.
80
- */
81
- function trackPathImport(node, unsafeFn, state) {
82
- if (node.importKind === 'type') {
83
- return;
84
- }
85
- for (const spec of node.specifiers) {
86
- if (
87
- spec.type === 'ImportSpecifier' &&
88
- spec.importKind !== 'type' &&
89
- spec.imported.name === unsafeFn &&
90
- spec.local.name === unsafeFn
91
- ) {
92
- state.namedImportSpec = spec;
93
- state.namedImportNode = node;
94
- }
95
- if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
96
- state.defaultImportName = spec.local.name;
97
- }
98
- }
99
- }
100
-
101
- /**
102
- * Is `name` re-exported by a bare `export { name }` in this file?
103
- *
104
- * Removing the import specifier then leaves the export naming nothing, and the
105
- * result does not PARSE — `Export 'join' is not defined`. An autofix whose
106
- * output cannot be parsed is the worst outcome available, so the specifier
107
- * stays and the call sites are still rewritten. Whatever is left is a lint
108
- * finding a human can read, not a broken file.
109
- */
110
- function isReExported(sourceCode, name) {
111
- return sourceCode.ast.body.some(
112
- (node) =>
113
- node.type === 'ExportNamedDeclaration' &&
114
- !node.source &&
115
- node.specifiers.some((spec) => spec.local?.name === name),
116
- );
117
- }
118
-
119
- /**
120
- * Track safe module import from an import declaration.
121
- */
122
- function trackSafeImport(node, state) {
123
- state.safeImportNode = node;
124
- for (const spec of node.specifiers) {
125
- if (spec.type === 'ImportSpecifier' && spec.imported.name === SAFE_OBJECT) {
126
- state.hasSafePathImport = true;
127
- }
128
- }
129
- }
130
-
131
- /**
132
- * Is `name` resolvable from `node`'s scope outward — a parameter, a local, an
133
- * import, or a configured global?
134
- *
135
- * Used only to decide whether a bare `join(...)` with no `node:path` import is
136
- * OUR `join` or somebody else's. `import { join } from 'lodash'` binds the name
137
- * and is not our business; an unbound `join` is a ReferenceError waiting to
138
- * happen, and — see `classifyCall` — is exactly what a half-applied autofix
139
- * leaves behind.
140
- */
141
- function isIdentifierBound(sourceCode, node, name) {
142
- for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
143
- if (scope.variables.some((variable) => variable.name === name)) {
144
- return true;
145
- }
146
- }
147
- return false;
148
- }
149
-
150
- /**
151
- * Check if a call expression is an unsafe path function call.
152
- *
153
- * Returns `{ isNamed }` — or `{ importOnly: true }` for a call that is already
154
- * correct and merely missing its import — or null if not a match.
155
- */
156
- function classifyCall(node, unsafeFn, state, sourceCode) {
157
- const isMember = node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier';
158
-
159
- // Direct call: join(...)
160
- if (node.callee.type === 'Identifier' && node.callee.name === unsafeFn) {
161
- if (state.namedImportSpec) {
162
- return { isNamed: true };
163
- }
164
- // REPAIR LEG. Keying detection on "did I see the import?" made this rule
165
- // stop reporting the moment a fix removed the specifier, so a partial
166
- // `--fix` reached a stable fixpoint over source that no longer compiles and
167
- // exited clean.
168
- //
169
- // Gated on `safePath` already being bound, which is what makes it a repair
170
- // rather than a second, sloppier detector. An unbound `join` is NOT reliably
171
- // our `join`: ESLint scope analysis does not bind `declare global { function
172
- // join() }`, and it cannot see an ambient global from a `globals.d.ts`, an
173
- // `@types` package, or a bundler — `resolve` and `relative` are entirely
174
- // plausible as those. Requiring `safePath` in scope narrows this to the
175
- // half-migrated file it exists to finish, where the name really was ours.
176
- if (state.safePathBoundInSource && !isIdentifierBound(sourceCode, node.callee, unsafeFn)) {
177
- return { isNamed: false };
178
- }
179
- return null;
180
- }
181
-
182
- // Namespace call: path.join(...)
183
- if (
184
- isMember &&
185
- node.callee.object.type === 'Identifier' &&
186
- node.callee.object.name === state.defaultImportName &&
187
- node.callee.property.name === unsafeFn
188
- ) {
189
- return { isNamed: false };
190
- }
191
-
192
- // REPAIR LEG, the other half: `safePath.join(...)` with no `safePath` in
193
- // scope. This is what a partially-applied fix leaves — and without it, that
194
- // state is PERMANENT rather than transient.
195
- //
196
- // ESLint runs `fix()` for a problem BEFORE the `eslint-disable` filter
197
- // discards it, so a suppressed report on the first call site consumes the
198
- // once-per-file import edit and then throws it away. Every other call is
199
- // rewritten to `safePath.join`, nothing imports `safePath`, and no report
200
- // survives to carry the import on any later pass. Recognising the orphaned
201
- // call is what closes that loop; it costs one extra pass, and only in a file
202
- // that is already broken.
203
- if (
204
- isMember &&
205
- node.callee.object.type === 'Identifier' &&
206
- node.callee.object.name === SAFE_OBJECT &&
207
- node.callee.property.name === unsafeFn &&
208
- !isIdentifierBound(sourceCode, node.callee.object, SAFE_OBJECT)
209
- ) {
210
- return { importOnly: true };
211
- }
212
-
213
- return null;
214
- }
215
-
216
- /**
217
- * Build auto-fix for an unsafe path function call.
218
- *
219
- * ## Why the import edits are emitted at most ONCE per file
220
- *
221
- * ESLint merges the fixes one `fix()` yields into a SINGLE range spanning
222
- * `min..max`, and applies only non-overlapping ranges per pass. A fix that
223
- * touches both the import and its own call site therefore spans everything in
224
- * between — so N such reports produce N nested ranges, ESLint keeps the
225
- * shortest and DISCARDS THE REST.
226
- *
227
- * That is not an edge case, it is every file with more than one call site. The
228
- * import edit landed, the other calls did not, and (before `classifyCall` grew
229
- * its bare-call leg) the next pass could no longer see them because the
230
- * specifier it keyed on was gone. `--fix` reached a stable fixpoint over source
231
- * that does not compile and exited clean. An adopter measured 146 files left
232
- * with a dangling reference across one sweep — worst single file, 75 call sites.
233
- *
234
- * So: the shared edits belong to the first report, and every later report emits
235
- * a fix LOCAL to its own callee. Nothing overlaps, and one pass fixes the file.
236
- * `no-manual-path-normalize.cjs` carries the same guard for the same reason.
237
- *
238
- * Only the FIRST report's fix is self-sufficient, and that is load-bearing:
239
- * applying a later one ALONE — an editor's "fix this problem", or an
240
- * `eslint-disable` on the first call site — rewrites the call without adding
241
- * the import. ESLint runs `fix()` before the disable filter, so a suppressed
242
- * report consumes the once-per-file edit and then discards it.
243
- *
244
- * That state is recoverable rather than permanent ONLY because `classifyCall`
245
- * has a repair leg for an orphaned `safePath.join(...)`. Without it the file
246
- * stays broken through every subsequent `--fix`, because no report is left to
247
- * carry the import — measured, not reasoned about. An earlier draft of this
248
- * comment asserted the recovery came free from `hasSafePathImport` being seeded
249
- * from scope; that was wrong, and an adversarial run produced the stable broken
250
- * fixpoint to prove it.
251
- *
252
- * The shared edits still cannot be hoisted onto their own report: removing
253
- * `join` from the import while a suppressed `join(...)` call survives is the
254
- * same broken output reached a different way. `exemptFiles` opts a whole file
255
- * out.
256
- */
257
- function importSafePath(fixer, sourceCode, state) {
258
- if (state.safeImportNode) {
259
- const lastSpec = state.safeImportNode.specifiers.at(-1);
260
- return fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`);
261
- }
262
- const targetNode = state.namedImportNode || sourceCode.ast.body[0];
263
- const declaration = `import { ${SAFE_OBJECT} } from '${state.safeModule}';`;
264
- // Land the new import next to the imports, not after arbitrary code. A file
265
- // reported only through a repair leg may have no path import at all, and
266
- // `insertTextAfter(body[0])` would push the declaration below the statement
267
- // that needs it — legal, since imports hoist, but it reads as though the
268
- // fixer lost track of the file.
269
- return targetNode.type === 'ImportDeclaration'
270
- ? fixer.insertTextAfter(targetNode, `\n${declaration}`)
271
- : insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`);
272
- }
273
-
274
- function buildFix(fixer, node, unsafeFn, classification, sourceCode, state) {
275
- // REPAIR: an orphaned `safePath.join(...)` is already the call we want, and
276
- // the only thing missing is the import that a discarded report was carrying.
277
- //
278
- // This deliberately ignores `state.hasSafePathImport`. That flag is mutated
279
- // inside `fix()`, and ESLint runs `fix()` for a SUPPRESSED problem before the
280
- // disable filter throws it away — so on every pass the suppressed report
281
- // spends the flag first and the repair emits nothing. The file then never
282
- // recovers, which is precisely the stable broken fixpoint this leg exists to
283
- // break. The gate that makes ignoring the flag safe is immutable: this
284
- // classification is only reached when `safePath` is unbound in the SOURCE.
285
- //
286
- // Several orphaned calls yield the identical insert at the identical anchor,
287
- // so ESLint applies one and drops the rest as overlapping — which is the
288
- // desired outcome, not a hazard.
289
- if (classification.importOnly) {
290
- return [importSafePath(fixer, sourceCode, state)];
291
- }
292
-
293
- const fixes = [fixer.replaceText(node.callee, `${SAFE_OBJECT}.${unsafeFn}`)];
294
-
295
- if (!state.hasSafePathImport) {
296
- fixes.push(importSafePath(fixer, sourceCode, state));
297
- state.hasSafePathImport = true;
298
- }
299
-
300
- if (
301
- classification.isNamed &&
302
- state.namedImportNode &&
303
- !state.namedImportRemoved &&
304
- !isReExported(sourceCode, unsafeFn)
305
- ) {
306
- fixes.push(...removeSpecifier(fixer, sourceCode, state.namedImportNode, state.namedImportSpec));
307
- state.namedImportRemoved = true;
308
- }
309
-
310
- return fixes;
311
- }
312
-
313
- module.exports = function createPathFunctionRule(config) {
314
- const { unsafeFn, message } = config;
315
-
316
- return {
317
- meta: {
318
- type: 'problem',
319
- docs: {
320
- description: `Enforce safePath.${unsafeFn}() instead of path.${unsafeFn}()`,
321
- category: 'Cross-platform compatibility',
322
- recommended: true,
323
- },
324
- fixable: 'code',
325
- schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
326
- messages: {
327
- noUnsafePathFn: message,
328
- [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
329
- [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
330
- },
331
- },
332
-
333
- create(context) {
334
- if (exemptMatcherFor(context)(context.getFilename())) {
335
- // Still surface a malformed exemption list: the file we are standing in
336
- // may be exempt only BECAUSE the entry is unanchored.
337
- return {
338
- Program(node) {
339
- reportUnanchoredExemptEntries(context, node);
340
- },
341
- };
342
- }
343
-
344
- const sourceCode = context.getSourceCode();
345
- const state = {
346
- // Resolved per invocation — the option belongs to the consuming repo,
347
- // which may point different rules at different re-export entries.
348
- safeModule: resolveSafeModule(context, SAFE_PATH_MODULE),
349
- namedImportSpec: null,
350
- namedImportNode: null,
351
- // Both of these guard a SHARED edit against being emitted by more than
352
- // one report — see `buildFix` for what ESLint does with the overlap.
353
- namedImportRemoved: false,
354
- defaultImportName: null,
355
- // Seeded from SCOPE, not from "did I see an import from SAFE_MODULE?".
356
- // A file already importing `safePath` from the barrel needs the call
357
- // rewritten but must NOT gain a second binding of the same name.
358
- hasSafePathImport: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
359
- // The SAME question, answered once and never mutated. `hasSafePathImport`
360
- // flips to true the moment a fix inserts the import, and gating the
361
- // repair leg on a flag that the first report can flip would arm it for
362
- // the rest of THIS pass — re-admitting the ambient-global false positive
363
- // in any file that also has a `path.join()` to fix.
364
- safePathBoundInSource: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
365
- safeImportNode: null,
366
- // EVERY path-module declaration, not just the one carrying `unsafeFn`.
367
- // A file's dead binding is `import path from 'node:path'`, which
368
- // `trackPathImport` only ever recorded as a NAME. See `dead-import.cjs`.
369
- pathImportNodes: [],
370
- };
371
-
372
- return {
373
- Program(node) {
374
- reportUnanchoredExemptEntries(context, node);
375
- },
376
-
377
- 'Program:exit'() {
378
- reportDeadUnsafeImports(
379
- context,
380
- sourceCode,
381
- state.pathImportNodes,
382
- state.safePathBoundInSource,
383
- );
384
- },
385
-
386
- ImportDeclaration(node) {
387
- if (PATH_MODULES.has(node.source.value)) {
388
- state.pathImportNodes.push(node);
389
- trackPathImport(node, unsafeFn, state);
390
- }
391
- if (node.source.value === state.safeModule) {
392
- trackSafeImport(node, state);
393
- }
394
- },
395
-
396
- CallExpression(node) {
397
- const classification = classifyCall(node, unsafeFn, state, sourceCode);
398
- if (!classification) {
399
- return;
400
- }
401
-
402
- context.report({
403
- node,
404
- messageId: 'noUnsafePathFn',
405
- // The module name reaches the message through `{{safeModule}}` rather
406
- // than being spelled out in each rule's string, so the advice cannot
407
- // drift from where the fixer actually writes the import.
408
- data: { safeModule: state.safeModule },
409
- fix(fixer) {
410
- return buildFix(fixer, node, unsafeFn, classification, sourceCode, state);
411
- },
412
- });
413
- },
414
- };
415
- },
416
- };
417
- };