@vibe-agent-toolkit/utils 0.1.41 → 0.1.42

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 (116) hide show
  1. package/README.md +146 -31
  2. package/dist/asset.d.ts +9 -0
  3. package/dist/asset.d.ts.map +1 -0
  4. package/dist/asset.js +9 -0
  5. package/dist/asset.js.map +1 -0
  6. package/dist/crawl.d.ts +19 -0
  7. package/dist/crawl.d.ts.map +1 -0
  8. package/dist/crawl.js +19 -0
  9. package/dist/crawl.js.map +1 -0
  10. package/dist/file-crawler.d.ts +19 -2
  11. package/dist/file-crawler.d.ts.map +1 -1
  12. package/dist/file-crawler.js +19 -1
  13. package/dist/file-crawler.js.map +1 -1
  14. package/dist/fs-utils.d.ts +60 -3
  15. package/dist/fs-utils.d.ts.map +1 -1
  16. package/dist/fs-utils.js +88 -10
  17. package/dist/fs-utils.js.map +1 -1
  18. package/dist/fs.d.ts +7 -6
  19. package/dist/fs.d.ts.map +1 -1
  20. package/dist/fs.js +7 -6
  21. package/dist/fs.js.map +1 -1
  22. package/dist/git-utils.d.ts +2 -0
  23. package/dist/git-utils.d.ts.map +1 -1
  24. package/dist/git-utils.js +16 -0
  25. package/dist/git-utils.js.map +1 -1
  26. package/dist/git.d.ts +23 -0
  27. package/dist/git.d.ts.map +1 -0
  28. package/dist/git.js +23 -0
  29. package/dist/git.js.map +1 -0
  30. package/dist/gitignore-checker.d.ts +0 -9
  31. package/dist/gitignore-checker.d.ts.map +1 -1
  32. package/dist/gitignore-checker.js +0 -12
  33. package/dist/gitignore-checker.js.map +1 -1
  34. package/dist/glob/glob-pattern.js +1 -1
  35. package/dist/glob/glob-pattern.js.map +1 -1
  36. package/dist/glob.d.ts +8 -0
  37. package/dist/glob.d.ts.map +1 -0
  38. package/dist/glob.js +8 -0
  39. package/dist/glob.js.map +1 -0
  40. package/dist/path-core.d.ts +196 -0
  41. package/dist/path-core.d.ts.map +1 -0
  42. package/dist/path-core.js +251 -0
  43. package/dist/path-core.js.map +1 -0
  44. package/dist/path-utils.d.ts +12 -183
  45. package/dist/path-utils.d.ts.map +1 -1
  46. package/dist/path-utils.js +13 -237
  47. package/dist/path-utils.js.map +1 -1
  48. package/dist/path.d.ts +12 -0
  49. package/dist/path.d.ts.map +1 -0
  50. package/dist/path.js +12 -0
  51. package/dist/path.js.map +1 -0
  52. package/dist/process.d.ts +7 -4
  53. package/dist/process.d.ts.map +1 -1
  54. package/dist/process.js +7 -4
  55. package/dist/process.js.map +1 -1
  56. package/dist/project.d.ts +31 -0
  57. package/dist/project.d.ts.map +1 -0
  58. package/dist/project.js +31 -0
  59. package/dist/project.js.map +1 -0
  60. package/dist/safe-exec.d.ts.map +1 -1
  61. package/dist/safe-exec.js +8 -4
  62. package/dist/safe-exec.js.map +1 -1
  63. package/dist/spawn-hardened.d.ts.map +1 -1
  64. package/dist/spawn-hardened.js +4 -17
  65. package/dist/spawn-hardened.js.map +1 -1
  66. package/dist/template-entry.d.ts +10 -0
  67. package/dist/template-entry.d.ts.map +1 -0
  68. package/dist/template-entry.js +10 -0
  69. package/dist/template-entry.js.map +1 -0
  70. package/dist/testing.d.ts +8 -0
  71. package/dist/testing.d.ts.map +1 -0
  72. package/dist/testing.js +8 -0
  73. package/dist/testing.js.map +1 -0
  74. package/dist/windows-shell.d.ts +140 -8
  75. package/dist/windows-shell.d.ts.map +1 -1
  76. package/dist/windows-shell.js +218 -11
  77. package/dist/windows-shell.js.map +1 -1
  78. package/dist/yaml.d.ts +9 -0
  79. package/dist/yaml.d.ts.map +1 -0
  80. package/dist/yaml.js +9 -0
  81. package/dist/yaml.js.map +1 -0
  82. package/dist/zod.d.ts +10 -0
  83. package/dist/zod.d.ts.map +1 -0
  84. package/dist/zod.js +10 -0
  85. package/dist/zod.js.map +1 -0
  86. package/eslint/README.md +217 -0
  87. package/eslint/index.cjs +161 -0
  88. package/eslint/index.d.cts +60 -0
  89. package/eslint/rules/dead-import.cjs +201 -0
  90. package/eslint/rules/eslint-rule-factory.cjs +410 -0
  91. package/eslint/rules/exempt-path-matcher.cjs +265 -0
  92. package/eslint/rules/no-bare-dynamic-import-path.cjs +142 -0
  93. package/eslint/rules/no-child-process-execSync.cjs +23 -0
  94. package/eslint/rules/no-command-direct-factory.cjs +246 -0
  95. package/eslint/rules/no-file-url-string-concat.cjs +77 -0
  96. package/eslint/rules/no-fs-mkdirSync.cjs +23 -0
  97. package/eslint/rules/no-fs-promises-cp.cjs +36 -0
  98. package/eslint/rules/no-fs-realpathSync.cjs +23 -0
  99. package/eslint/rules/no-hardcoded-path-split.cjs +141 -0
  100. package/eslint/rules/no-manual-path-normalize.cjs +161 -0
  101. package/eslint/rules/no-os-tmpdir.cjs +24 -0
  102. package/eslint/rules/no-path-join.cjs +14 -0
  103. package/eslint/rules/no-path-operations-in-comparisons.cjs +148 -0
  104. package/eslint/rules/no-path-relative.cjs +14 -0
  105. package/eslint/rules/no-path-resolve.cjs +14 -0
  106. package/eslint/rules/no-path-sep-in-strings.cjs +130 -0
  107. package/eslint/rules/no-path-startswith.cjs +139 -0
  108. package/eslint/rules/no-test-scoped-functions.cjs +134 -0
  109. package/eslint/rules/no-unix-shell-commands.cjs +152 -0
  110. package/eslint/rules/no-unsafe-root-join.cjs +85 -0
  111. package/eslint/rules/no-url-pathname-for-fs.cjs +107 -0
  112. package/eslint/rules/path-function-rule-factory.cjs +417 -0
  113. package/eslint/rules/prefer-startswith-over-regex.cjs +257 -0
  114. package/eslint/rules/require-justified-skip.cjs +348 -0
  115. package/eslint/rules/safe-import.cjs +164 -0
  116. package/package.json +65 -7
@@ -0,0 +1,217 @@
1
+ # `@vibe-agent-toolkit/utils/eslint`
2
+
3
+ ESLint rules that enforce the cross-platform and agentic-code safety helpers in the rest of [`@vibe-agent-toolkit/utils`](https://www.npmjs.com/package/@vibe-agent-toolkit/utils).
4
+
5
+ Twenty-one rules, all of them derived from a bug that actually shipped: `os.tmpdir()` returning an 8.3 short path on a Windows CI runner, `path.join()` producing backslashes that then failed a string comparison, `await import(absolutePath)` throwing on Windows without a `file://` URL, `execSync()` interpolating a caller-controlled string into a shell. Most auto-fix.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @vibe-agent-toolkit/utils
11
+ ```
12
+
13
+ There is no separate plugin package: the rules are a subpath of the package whose helpers they enforce, so the two can never drift to different versions. Requires ESLint 9+ (flat config) and Node >= 22.
14
+
15
+ `eslint` is an **optional** peer dependency, and this subpath adds no dependency to the others. An ESLint plugin is data, not code that runs — every rule module here exports a plain object and none of them `require('eslint')` — so installing `utils` for `safePath.join()` alone pulls in nothing extra and warns about nothing.
16
+
17
+ ## Usage
18
+
19
+ ```js
20
+ // eslint.config.js
21
+ import vat from '@vibe-agent-toolkit/utils/eslint';
22
+
23
+ export default [
24
+ vat.configs.recommended,
25
+ ];
26
+ ```
27
+
28
+ `configs.recommended` registers the plugin under the `@vibe-agent-toolkit` namespace and enables the **cross-platform safety core** — 18 of the 21 rules, most at `error` and three at `warn` (see [Severities](#severities)). The other three are opt-in; the [rule tables](#rules) mark each rule's `recommended` severity, and `—` means not in `recommended`.
29
+
30
+ To pick rules yourself, register the plugin and name them:
31
+
32
+ ```js
33
+ import vat from '@vibe-agent-toolkit/utils/eslint';
34
+
35
+ export default [
36
+ {
37
+ files: ['**/*.{ts,tsx,js,cjs,mjs}'],
38
+ plugins: { '@vibe-agent-toolkit': vat },
39
+ rules: {
40
+ '@vibe-agent-toolkit/no-path-join': 'error',
41
+ '@vibe-agent-toolkit/no-os-tmpdir': 'error',
42
+ },
43
+ },
44
+ ];
45
+ ```
46
+
47
+ ### Exempting the file that implements the wrapper
48
+
49
+ A rule that bans `os.tmpdir()` has to let *something* call it — the file that implements your `normalizedTmpdir()`. That file's path is a fact about your repo, so the plugin ships **no** built-in exemptions; you declare them:
50
+
51
+ ```js
52
+ '@vibe-agent-toolkit/no-os-tmpdir': ['error', { exemptFiles: ['src/paths.ts'] }],
53
+ '@vibe-agent-toolkit/no-path-join': ['error', { exemptFiles: [
54
+ 'src/paths.ts',
55
+ 'test/paths.test.ts', // asserts the platform-native behavior the wrapper hides
56
+ ] }],
57
+ ```
58
+
59
+ Paths are repo-relative and matched at a **path-segment boundary**, never as a substring: declaring `src/paths.ts` does not exempt `tools/hooks/paths.ts`, `src/my-paths.ts`, or `src/paths.ts.bak`. (It used to be a substring check. A private `tools/hooks/path-utils.ts` full of raw `tmpdir()` calls linted clean for months.) Matching is separator-agnostic, so the same list works on Windows.
60
+
61
+ The match is anchored at the **end** of the path, so a declaration is a suffix: `src/paths.ts` also exempts `packages/anything/src/paths.ts`. Name enough leading segments to be unambiguous in your tree (this repo declares `packages/utils/src/path-utils.ts`, not `path-utils.ts`).
62
+
63
+ Taken to its limit, a **bare filename with no `/` exempts that filename everywhere in the repo** — including files added later by someone who never read the config. The rules report that as `unanchoredExemptFile` rather than letting it pass, because it is the same repo-wide hole the segment-boundary matching exists to close. `./paths.ts` is the same hole with a slash in it, so the check runs on the normalized entry, not the raw string.
64
+
65
+ The option **replaces** any default rather than merging with it, and unknown option keys are a config error — a typo'd `exemptFile` must fail loudly rather than quietly exempt nothing.
66
+
67
+ The rules taking `exemptFiles` are `no-path-join`, `no-path-resolve`, `no-path-relative`, `no-os-tmpdir`, `no-fs-mkdirSync`, `no-fs-realpathSync`, `no-child-process-execSync`, and `no-fs-promises-cp`.
68
+
69
+ ## Rules
70
+
71
+ The "use instead" column names the `@vibe-agent-toolkit/utils` subpath the replacement lives on; ✓ marks an auto-fix. The **`recommended`** column is the severity `configs.recommended` assigns — `—` means the rule is **not** in `recommended` and must be enabled explicitly.
72
+
73
+ **The auto-fix writes the import to the subpath in that column**, not to the barrel — `--fix` on a raw `path.join()` inserts `import { safePath } from '@vibe-agent-toolkit/utils/path'`. A file that already reaches the helper through the barrel keeps its existing import and only has the call rewritten: adding a second binding of the same name is a `SyntaxError`, not a redundant import.
74
+
75
+ ### Pointing the fix at your own re-export seam — `safeModule`
76
+
77
+ If your repo re-exports these helpers through its own module, the defaults above are wrong for you, and not merely stylistically: in a workspace with isolated `node_modules` (pnpm, Yarn PnP), an import of a package the receiving package does not declare **fails to resolve**. One adopter measured 620 files across 52 such packages. Point the rule at the module that resolves where the fix lands:
78
+
79
+ ```js
80
+ '@vibe-agent-toolkit/no-path-join': ['warn', { safeModule: '@acme/dev-tools/paths' }],
81
+ '@vibe-agent-toolkit/no-os-tmpdir': ['error', { safeModule: '@acme/dev-tools/fs' }],
82
+ ```
83
+
84
+ It is **per-rule**, not one shared `settings` key, because a seam need not split its symbols the way this package does — an adopter whose narrow entry carried `normalizedTmpdir()` but not `safePath` needed the two rules pointed at different modules, which a single key cannot express. Note the two lines above therefore name *different* modules. It composes with `exemptFiles`, and it changes the error message as well as the fix, so the advice never names a module you don't use. Every rule that names a module accepts it, including the ones that only advise and never fix.
85
+
86
+ > **Point each rule at a module that exports the symbol *that rule writes*, not merely one that resolves.** This is the failure mode worth spending a minute on, because it is the quiet one. A `safeModule` that doesn't resolve fails loudly and immediately. A `safeModule` that resolves but lacks the symbol passes every resolution check — including an explicit `import()` probe — and then throws `Cannot read properties of undefined` at each call site, once per fixed file. Aim `no-path-join` at an entry without `safePath` and you get a green `--fix`, a green install, and thousands of latent `TypeError`s. The rules cannot check this for you: verifying it would mean resolving and importing your module from inside the linter, which is neither its job nor reliable from wherever ESLint happens to be running.
87
+
88
+ Two ways your target can be wrong, which surface differently: `ERR_MODULE_NOT_FOUND` means the receiving package doesn't declare it at all, while `ERR_PACKAGE_PATH_NOT_EXPORTED` means it does — but the version resolved *at that location* doesn't export the subpath. In a monorepo mid-upgrade those differ, so the question is never "does the package declare it" but "does the resolved version there export it."
89
+
90
+ ### Path handling
91
+
92
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
93
+ |---|---|---|---|---|---|
94
+ | `no-path-join` | `path.join()` | `safePath.join()` | `/path` | ✓ | `warn` |
95
+ | `no-path-resolve` | `path.resolve()` | `safePath.resolve()` | `/path` | ✓ | `warn` |
96
+ | `no-path-relative` | `path.relative()` | `safePath.relative()` | `/path` | ✓ | `warn` |
97
+ | `no-path-startswith` | `path.startsWith()` on a raw path | `toForwardSlash()` first | `/path` | | `error` |
98
+ | `no-hardcoded-path-split` | `split('/')` / `split('\\')` on a path | `path.basename()`, or `toForwardSlash()` first | `/path` | | `error` |
99
+ | `no-path-sep-in-strings` | `path.sep` embedded in a string literal | `toForwardSlash()` | `/path` | | `error` |
100
+ | `no-manual-path-normalize` | hand-rolled `.replace(/\\/g, '/')` | `toForwardSlash()` | `/path` | ✓ | `error` |
101
+ | `no-path-operations-in-comparisons` | raw `path.*()` results in string comparisons | wrap in `toForwardSlash()` | `/path` | | `error` |
102
+ | `no-unsafe-root-join` | `safePath.join(someRoot, x)` where `x` can escape | `safePath.joinUnderRoot()` | `/path` | | — |
103
+
104
+ ### Filesystem and process
105
+
106
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
107
+ |---|---|---|---|---|---|
108
+ | `no-os-tmpdir` | `os.tmpdir()` (8.3 short names on Windows) | `normalizedTmpdir()` | `/fs` | ✓ | `error` |
109
+ | `no-fs-realpathSync` | `fs.realpathSync()` | `normalizePath()` | `/fs` | ✓ | `error` |
110
+ | `no-fs-mkdirSync` | `fs.mkdirSync()` | `mkdirSyncReal()` | `/fs` | ✓ | `error` |
111
+ | `no-fs-promises-cp` | `cp()` from `node:fs/promises` (drops nested files on Node 22) | `cpSync()` from `node:fs` | — | ✓ | `error` |
112
+ | `no-child-process-execSync` | `child_process.execSync()` | `safeExecSync()` | `/process` | ✓ | `error` |
113
+ | `no-unix-shell-commands` | `tar`, `grep`, `rm`, `echo`, … spawned directly | Node APIs, or a portable script fixture | — | | `error` |
114
+
115
+ The member-call rules here check the **receiver**, not just the method name, so `env.tmpdir()` on some unrelated object is not a finding — and the namespace they check for can be bound by a static `import * as os`, by `const os = require('node:os')`, or by `const os = await import('node:os')`. The fix replaces the whole callee (`os.tmpdir()` → `normalizedTmpdir()`), which is correct however the binding was made. Matching the method name alone was the earlier behaviour and it produced `os.normalizedTmpdir()` — a method that does not exist, compiles, and throws.
116
+
117
+ ### URLs and dynamic imports
118
+
119
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
120
+ |---|---|---|---|---|---|
121
+ | `no-url-pathname-for-fs` | `new URL(x, import.meta.url).pathname` as a filesystem path | `resolveFromImportMeta()` / `fileURLToPath()` | `/fs` | | `error` |
122
+ | `no-bare-dynamic-import-path` | `await import(absolutePath)` | `dynamicImportPath()` / `pathToFileURL(p).href` | `/fs` | | `error` |
123
+ | `no-file-url-string-concat` | `` `file://${p}` `` | `pathToFileURL(p).href` | — | | `error` |
124
+
125
+ ### Code and test hygiene
126
+
127
+ | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
128
+ |---|---|---|---|---|---|
129
+ | `prefer-startswith-over-regex` | `/^foo/.test(s)`, `` /^\*glob/.test(s) ``, `const RE = /^foo/; RE.test(s)` | `s.startsWith('foo')` | — | | `error` |
130
+ | `no-test-scoped-functions` | helper functions declared inside `describe`/`it` | module scope | — | | — |
131
+ | `require-justified-skip` | unannotated `it.skip`/`it.todo`, tautological assertions, empty test bodies | a `SKIP(#123): reason` annotation, or a real assertion | — | | — |
132
+
133
+ ### What `recommended` deliberately leaves out
134
+
135
+ Three rules ship without riding in `recommended`, for two different reasons.
136
+
137
+ **Test-style opinions** — `no-test-scoped-functions` (where a helper may be declared) and `require-justified-skip` (the annotation grammar for a disabled test). Neither is a portability or correctness fact, and installing this package for `safePath.join()` should not also import someone else's test conventions. Both are worth turning on deliberately.
138
+
139
+ **Unsound, pending a rewrite** — `no-unsafe-root-join`. It keys on whether an identifier's *name* ends in `root` rather than on whether any segment is caller-controlled, which makes it noisy and blind at the same time. Measured against 4,670 files of real adopter source: 108 findings, none autofixable. Verified behaviour:
140
+
141
+ ```js
142
+ safePath.join(repoRoot, 'docs', 'product') // FIRES — all literals, nothing can escape
143
+ safePath.resolve(packageRoot, '..', '..') // FIRES — escaping is the intent; the fix breaks it
144
+ safePath.join(repoRoot) // FIRES — one argument, no segment at all
145
+ safePath.join(base, userInput) // SILENT — the shape it exists to catch
146
+ ```
147
+
148
+ A rule that misses its own target does not belong in a config named `recommended` at any severity: a safety core that cries wolf teaches people to ignore it, and that costs you the true positives too. It still ships, and it still earns `error` when scoped to directories where a path escape is a security boundary — which is how this repo uses it, on its skill-test staging code. It will return to `recommended` when it keys on taint rather than on naming.
149
+
150
+ Enable any of the three by naming it:
151
+
152
+ ```js
153
+ import vat from '@vibe-agent-toolkit/utils/eslint';
154
+
155
+ export default [
156
+ vat.configs.recommended,
157
+ {
158
+ rules: {
159
+ '@vibe-agent-toolkit/no-test-scoped-functions': 'error',
160
+ '@vibe-agent-toolkit/require-justified-skip': 'error',
161
+ },
162
+ },
163
+ {
164
+ // Scope it to where an escape is a security boundary, not repo-wide.
165
+ files: ['src/staging/**/*.ts'],
166
+ rules: { '@vibe-agent-toolkit/no-unsafe-root-join': 'error' },
167
+ },
168
+ ];
169
+ ```
170
+
171
+ ### Severities
172
+
173
+ Within `recommended`, `error` is the default; three rules are `warn`:
174
+
175
+ - **`no-path-join`, `no-path-resolve`, `no-path-relative`** — by far the highest-churn rules; they fire on every raw `node:path` call in an existing codebase. Measured on a 4,670-file adopter tree: 3,963 + 372 + 1 findings, **every one of them autofixable**. `warn` lets a project run `--fix` and burn the list down incrementally instead of blocking CI on day one.
176
+
177
+ Raise all three to `error` once the backlog is clear. That is what this repo does.
178
+
179
+ The criterion for `warn` is **migration volume**, not how real the finding is — a rule whose findings we doubted would be out of `recommended` entirely, not demoted. Everything at `error` either prevents a bug or moves a static-analysis finding left of a merge.
180
+
181
+ ### Running `--fix` over a large backlog
182
+
183
+ Every rule that rewrites a call *and* edits imports fixes **all** of a file's call sites in a single pass, and `packages/utils/test/eslint/rules.test.ts` holds each of them to that: it runs `--fix` to its fixpoint and then asks `no-undef` whether the result still binds every identifier.
184
+
185
+ That test exists because the answer used to be no. ESLint merges the fixes one `fix()` yields into a **single range spanning `min..max`**, and applies only non-overlapping ranges per pass — so a fix touching both the import and its own call site spanned everything in between, N call sites produced N nested ranges, and ESLint kept one. The rule then went quiet, because the import specifier its detection keyed on was what had just been removed. `--fix` reached a stable fixpoint over source that no longer compiles and exited clean; you found out at `tsc`. An adopter measured **146 files left with a dangling reference** across one ~4,900-site sweep, worst single file 75 unrewritten calls.
186
+
187
+ `eslint-disable` interacts with this in a way worth knowing about, because it is not obvious and it took an adversarial run to find. ESLint invokes a rule's `fix()` **before** the disable filter discards the problem, so a suppressed report still consumes any once-per-file edit its rule was holding. Where that edit is an import *insert*, the rules either re-emit it from every report or carry a repair leg that recognises the orphaned call and supplies the missing import on the next pass — so a disabled call site costs an extra pass, not a broken file. Where it is an import *removal*, the removal is latched and simply goes away with the discarded report, leaving an unused import for `no-unused-vars` to point at rather than a call with nothing behind it. `exemptFiles` remains the supported way to opt a whole file out.
188
+
189
+ Two more things a fixer here will not do: delete a `type`-only, aliased, or re-exported specifier (removing a re-exported one produced output that did not parse), and insert an import *between* a leading `eslint-disable-next-line` and the statement it protects, which would silently revoke the suppression.
190
+
191
+ #### The binding left behind
192
+
193
+ `path.join(a, b)` becomes `safePath.join(a, b)`, and when that was the file's last `path.*` reference the `import path from 'node:path'` is left bound to nothing. That is not a dangling reference, so the `no-undef` fixpoint check above is blind to it — and the same adopter measured **536 errors surviving a converged `--fix` across 232 files** (289 `no-unused-vars`, 247 `sonarjs/unused-import`), every one of them this. In a repo gating at `--max-warnings=0`, `--fix` output that does not lint clean is not a finished migration.
194
+
195
+ So the rules now report it themselves, as a separate `deadUnsafeImport` finding on the import line with its own fix. Being its own finding is the point: it shows up in lint output and you can `eslint-disable` it, rather than a call rewrite quietly taking a declaration with it.
196
+
197
+ Deliberately narrow:
198
+
199
+ - **A closed list of modules** — `node:path`, `node:os`, `node:fs`, `node:fs/promises`, `node:child_process` and their bare spellings. All Node builtins, all side-effect-free with certainty decided when the rule was written. This is *not* a general unused-import rule and will not become one; for blanket cleanup, `eslint-plugin-unused-imports` already exists and already autofixes.
200
+ - **Only in a file these rules migrated** — the safe symbol must already be bound. A dead import in a file this pack never touched is somebody else's business.
201
+ - **Only whole declarations, only with zero references left** — evaluated against the source as it stands on that pass, so the removal always lands *after* the rewrite that consumed the last reference, never speculatively beside it. A partially-dead declaration (`import path, { sep }` with `sep` still live) is left alone, as are bare `import 'node:path'` side-effect imports and anything carrying a `type` specifier.
202
+
203
+ This cannot be delegated: `@typescript-eslint/no-unused-vars` declares `meta.fixable: 'code'` but emits only a **suggestion** for an unused import, and `--fix` never applies suggestions; `sonarjs/unused-import` declares no fixer at all. Verified with both enabled alongside these rules in one `verifyAndFix` — the import survived. Those rules abstain for a good reason, since removing an import can change behaviour; a rule that *created* the orphan knows it just consumed the last reference and knows the module, so it can act where a generic rule cannot.
204
+
205
+ ## Why custom rules
206
+
207
+ A cross-platform safety helper is only as good as its enforcement. `safePath.join()` prevents a class of Windows bug precisely once — the moment someone writes `path.join()` instead, the helper's existence has bought nothing. Publishing the API without the lint rule ships half a product: the fix is available, and nothing directs anyone to it.
208
+
209
+ What these rules buy is *when* you find out. Without them, a raw `os.tmpdir()` is found by a Windows CI job — a different machine, twenty minutes later, on someone else's push, with a failure message about a path that doesn't obviously relate to the line that caused it. With them, it is a red squiggle under the call, on the author's machine, with the replacement named in the message and usually applied by `--fix`. The bug never leaves the editor. That is the whole argument, and it is why the rules are written to name the replacement rather than merely to forbid the primitive.
210
+
211
+ The second reason is agentic. When most code in a repo is written by an LLM, a convention that lives only in a style guide or a CLAUDE.md gets re-violated constantly — the model is confident, `path.join` is what it saw a million times in training, and the guidance was three thousand tokens back. A lint rule is feedback the model receives at the moment it is wrong, which is the only moment it can act on. Treat rules as the durable form of any convention you'd otherwise repeat in a prompt.
212
+
213
+ Most projects will want rules the ones here don't cover, for invariants only that project has: a deprecated internal API, a logging call that must carry a request ID, a module boundary nothing may import across. These rules are readable, small, and built on two shared factories plus a segment-anchored path matcher — copy the shape. `eslint-rule-factory.cjs` handles "ban function X from module Y, suggest Z, fix the import"; `exempt-path-matcher.cjs` handles the exemption question every such rule eventually asks, and is worth reading before you write `filename.includes(...)`.
214
+
215
+ ## License
216
+
217
+ MIT
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `@vibe-agent-toolkit/utils/eslint` — cross-platform and agentic-code safety rules.
3
+ *
4
+ * These rules enforce the safe helpers published by the rest of this package
5
+ * (`safePath.*` on `/path`, `normalizedTmpdir()`, `mkdirSyncReal()`,
6
+ * `normalizePath()` on `/fs`, `safeExecSync()` on `/process`). The helpers exist
7
+ * because the raw primitives they wrap have platform potholes; the rules exist so
8
+ * a call to the raw primitive fails at lint time on the author's machine rather
9
+ * than in CI on a different OS.
10
+ *
11
+ * They ship as a SUBPATH rather than a separate package because an ESLint plugin
12
+ * is data, not code that runs: every module below exports a plain rule object and
13
+ * none of them `require('eslint')`. So this entry resolves — and the other twelve
14
+ * subpaths keep resolving — whether or not ESLint is installed, which is why
15
+ * `eslint` is declared as an OPTIONAL peer dependency. One install, one version,
16
+ * no way for the rules to drift from the helpers they name.
17
+ *
18
+ * CommonJS on purpose, in an ESM package: the rule modules are `.cjs`, Node keys
19
+ * module format off the extension regardless of the package's `"type"`, and a CJS
20
+ * entry point can be both `require()`d from an `eslint.config.cjs` and `import`ed
21
+ * from an `eslint.config.js`/`.mjs`.
22
+ *
23
+ * Rules whose exemptions name a file (the ONE implementation file allowed to call
24
+ * the primitive) take an `exemptFiles` option — see README.md. The shipped
25
+ * defaults are EMPTY: an exemption is a claim about a specific repo's layout, and
26
+ * inheriting another repo's claim is how a same-named file silently opts itself
27
+ * out of a rule.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const rules = {
33
+ 'no-child-process-execSync': require('./rules/no-child-process-execSync.cjs'),
34
+ 'no-hardcoded-path-split': require('./rules/no-hardcoded-path-split.cjs'),
35
+ 'no-path-startswith': require('./rules/no-path-startswith.cjs'),
36
+ 'no-unix-shell-commands': require('./rules/no-unix-shell-commands.cjs'),
37
+ 'no-os-tmpdir': require('./rules/no-os-tmpdir.cjs'),
38
+ 'no-fs-mkdirSync': require('./rules/no-fs-mkdirSync.cjs'),
39
+ 'no-fs-realpathSync': require('./rules/no-fs-realpathSync.cjs'),
40
+ 'no-manual-path-normalize': require('./rules/no-manual-path-normalize.cjs'),
41
+ 'no-path-sep-in-strings': require('./rules/no-path-sep-in-strings.cjs'),
42
+ 'no-path-operations-in-comparisons': require('./rules/no-path-operations-in-comparisons.cjs'),
43
+ 'no-path-join': require('./rules/no-path-join.cjs'),
44
+ 'no-path-resolve': require('./rules/no-path-resolve.cjs'),
45
+ 'no-path-relative': require('./rules/no-path-relative.cjs'),
46
+ 'no-test-scoped-functions': require('./rules/no-test-scoped-functions.cjs'),
47
+ 'no-fs-promises-cp': require('./rules/no-fs-promises-cp.cjs'),
48
+ 'no-url-pathname-for-fs': require('./rules/no-url-pathname-for-fs.cjs'),
49
+ 'no-bare-dynamic-import-path': require('./rules/no-bare-dynamic-import-path.cjs'),
50
+ 'no-file-url-string-concat': require('./rules/no-file-url-string-concat.cjs'),
51
+ 'prefer-startswith-over-regex': require('./rules/prefer-startswith-over-regex.cjs'),
52
+ 'no-unsafe-root-join': require('./rules/no-unsafe-root-join.cjs'),
53
+ 'require-justified-skip': require('./rules/require-justified-skip.cjs'),
54
+ };
55
+
56
+ /**
57
+ * Rules deliberately LEFT OUT of `configs.recommended`.
58
+ *
59
+ * `recommended` is the cross-platform-safety core: every rule in it flags a call
60
+ * that is wrong (or unportable) regardless of how the adopting project likes to
61
+ * write tests. The two below are neither — they encode a position on TEST STYLE:
62
+ *
63
+ * - `require-justified-skip` — a specific annotation grammar (`SKIP(#123): reason`)
64
+ * for a disabled test, plus a view on what counts as a tautological assertion.
65
+ * - `no-test-scoped-functions` — a view on WHERE a test helper may be declared
66
+ * (module scope, never inside `describe`/`it`).
67
+ *
68
+ * Someone installing this package for `safePath.join()` should not silently
69
+ * inherit either. Both ship in `rules` and stay enabled explicitly:
70
+ *
71
+ * '@vibe-agent-toolkit/require-justified-skip': 'error',
72
+ *
73
+ * That is exactly what VAT's own `eslint.config.js` does — it does not consume
74
+ * `configs.recommended` at all, so this exclusion changes nothing about how this
75
+ * repo lints itself.
76
+ */
77
+ const RECOMMENDED_EXCLUDE = new Set([
78
+ 'require-justified-skip',
79
+ 'no-test-scoped-functions',
80
+ // Excluded for a DIFFERENT reason than the two above: not a style opinion, but
81
+ // an unsound heuristic. It keys on whether an identifier's name ends in `root`
82
+ // rather than on whether any segment is caller-controlled, which makes it
83
+ // simultaneously noisy and blind. Measured on a 4,670-file adopter tree: 108
84
+ // findings, 0 autofixable, and every one of these verified by execution here:
85
+ //
86
+ // FIRES safePath.join(repoRoot, 'docs', 'product') <- all literals, cannot escape
87
+ // FIRES safePath.resolve(packageRoot, '..', '..') <- escaping IS the intent; the fix breaks it
88
+ // FIRES safePath.join(repoRoot) <- one argument, no segment at all
89
+ // silent safePath.join(base, userInput) <- THE dangerous shape, missed
90
+ //
91
+ // A rule that misses the case it exists to catch must not ride in a config
92
+ // named `recommended` at any severity — a safety core that cries wolf teaches
93
+ // people to ignore it, which costs the true positives too. It still ships, and
94
+ // it still earns `error` where scoped to directories in which a path escape is
95
+ // a security boundary (this repo scopes it to the skill-test staging code).
96
+ // Re-include it when it keys on taint rather than on naming.
97
+ 'no-unsafe-root-join',
98
+ ]);
99
+
100
+ /**
101
+ * Default severities for `configs.recommended`.
102
+ *
103
+ * `error` is the default: every rule below flags a call whose replacement is a
104
+ * one-line swap, and a wrong answer is a real bug on some platform.
105
+ *
106
+ * `warn` is reserved for the case where a fresh adopter's first run would
107
+ * otherwise be a wall of blocking errors they cannot triage in one sitting:
108
+ * `no-path-join` / `no-path-resolve` / `no-path-relative`, the highest-churn
109
+ * rules by far (they fire on every raw `node:path` call in the codebase).
110
+ * Measured on a 4,670-file adopter tree: 3,963 + 372 + 1 findings, **all
111
+ * autofixable**. All three auto-fix, so `warn` lets a project run `--fix` and
112
+ * burn the list down incrementally instead of blocking CI on day one.
113
+ *
114
+ * Raise them to `error` once the backlog is clear — that is what VAT itself does.
115
+ *
116
+ * The criterion for `warn` is MIGRATION VOLUME, not how real the finding is. Every
117
+ * rule in this pack either prevents a bug or shifts a static-analysis finding left of
118
+ * a merge, and both are worth blocking on; what `warn` buys is a first run that reads
119
+ * as a backlog to `--fix` rather than a wall. `prefer-startswith-over-regex` was
120
+ * briefly graded on a different axis ("style, not a defect") and demoted — that was
121
+ * wrong twice over: avoiding a SonarQube S6557 at lint time instead of at merge time
122
+ * is a real saving, and the rule's matcher rejects any regex containing a
123
+ * metacharacter, so it only fires on true literal prefixes and has near-zero churn.
124
+ * It is `error`.
125
+ */
126
+ const RECOMMENDED_WARN = new Set([
127
+ 'no-path-join',
128
+ 'no-path-resolve',
129
+ 'no-path-relative',
130
+ ]);
131
+
132
+ /**
133
+ * Plugin namespace an adopter gets from `configs.recommended`, and therefore the
134
+ * prefix on every rule id (`@vibe-agent-toolkit/no-path-join`).
135
+ *
136
+ * Deliberately the SCOPE, not the full subpath specifier: rule ids are the surface
137
+ * adopters type into `rules`, `eslint-disable` comments and CI baselines, and they
138
+ * should not have to change if the pack ever moves house again. (This repo's own
139
+ * config registers the same object under `local` for exactly that reason.)
140
+ */
141
+ const NAMESPACE = '@vibe-agent-toolkit';
142
+
143
+ const plugin = {
144
+ meta: {
145
+ name: '@vibe-agent-toolkit/utils/eslint',
146
+ },
147
+ rules,
148
+ configs: {},
149
+ };
150
+
151
+ plugin.configs.recommended = {
152
+ name: '@vibe-agent-toolkit/utils/eslint/recommended',
153
+ plugins: { [NAMESPACE]: plugin },
154
+ rules: Object.fromEntries(
155
+ Object.keys(rules)
156
+ .filter((name) => !RECOMMENDED_EXCLUDE.has(name))
157
+ .map((name) => [`${NAMESPACE}/${name}`, RECOMMENDED_WARN.has(name) ? 'warn' : 'error']),
158
+ ),
159
+ };
160
+
161
+ module.exports = plugin;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Types for `@vibe-agent-toolkit/utils/eslint`.
3
+ *
4
+ * Hand-written, and deliberately structural rather than `import type { ESLint }
5
+ * from 'eslint'`: `eslint` is an OPTIONAL peer dependency, so a consumer who took
6
+ * this package for `safePath.join()` has no `eslint` types installed, and a
7
+ * reference to them here would turn this entry into a type error for exactly the
8
+ * people the optional peer exists to spare. The shapes below are the subset flat
9
+ * config consumes, and are structurally assignable to `ESLint.Plugin`.
10
+ *
11
+ * `export =`, not `export default`: the implementation ends in
12
+ * `module.exports = plugin`, and `export =` is the only declaration form that says
13
+ * so. Consumers with `esModuleInterop` (the default for this repo's base config)
14
+ * still write `import vat from '@vibe-agent-toolkit/utils/eslint'`.
15
+ *
16
+ * `.d.cts`, not `.d.ts`: under `moduleResolution: node16`/`nodenext` TypeScript
17
+ * matches the declaration's extension to the module format of the file it
18
+ * describes, and `index.cjs` is CommonJS inside a `"type": "module"` package.
19
+ *
20
+ * This file exists because `eslint.config.ts` is supported from ESLint 9.18.
21
+ * Without it, an adopter writing one gets TS7016 on the import. It is NOT covered
22
+ * by `bun run typecheck` (utils' tsconfig includes only `src/**‍/*.ts`), so the
23
+ * packaged-artifact integration test compiles a consumer fixture against it
24
+ * instead — see `test/integration/eslint-recommended-config.integration.test.ts`.
25
+ */
26
+
27
+ declare const plugin: plugin.Plugin;
28
+
29
+ declare namespace plugin {
30
+ /** A rule module, opaque here — adopters hand these to ESLint, never call them. */
31
+ interface RuleModule {
32
+ meta?: Record<string, unknown>;
33
+ create: (context: unknown) => Record<string, unknown>;
34
+ }
35
+
36
+ /** A flat-config object, as returned by `configs.recommended`. */
37
+ interface FlatConfig {
38
+ name: string;
39
+ plugins: Record<string, Plugin>;
40
+ /** Rule id → severity. Keys are namespaced: `@vibe-agent-toolkit/no-path-join`. */
41
+ rules: Record<string, 'error' | 'warn' | 'off'>;
42
+ }
43
+
44
+ interface Plugin {
45
+ meta: { name: string };
46
+ /** Rule name WITHOUT the namespace prefix, e.g. `no-path-join`. */
47
+ rules: Record<string, RuleModule>;
48
+ configs: {
49
+ /**
50
+ * The cross-platform safety core: 18 of the 21 rules, 15 `error` / 3 `warn`.
51
+ * `no-test-scoped-functions` and `require-justified-skip` are excluded — they
52
+ * are positions on test style, not portability facts. Both still ship in
53
+ * `rules` and are enabled by naming them.
54
+ */
55
+ recommended: FlatConfig;
56
+ };
57
+ }
58
+ }
59
+
60
+ export = plugin;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Removing the import binding THIS PACK's own fixers just orphaned.
3
+ *
4
+ * ## The gap this closes
5
+ *
6
+ * `path.join(a, b)` rewrites to `safePath.join(a, b)`, and when that was the
7
+ * file's last `path.*` reference the `import path from 'node:path'` is left
8
+ * bound to nothing. An adopter measured the consequence over ~5,100 sites:
9
+ * `--fix` converged, and **536 errors survived across 232 files** — 289
10
+ * `no-unused-vars` plus 247 `sonarjs/unused-import`, every one of them this same
11
+ * class. Their repo gates at `--max-warnings=0`, so the fixed output did not
12
+ * lint clean and the migration was not actually complete.
13
+ *
14
+ * The `no-undef` fixpoint check the rest of this pack leans on is structurally
15
+ * blind to it: a dead import leaves nothing DANGLING, it leaves something SPARE.
16
+ *
17
+ * ## Why this cannot be delegated to the ecosystem
18
+ *
19
+ * `@typescript-eslint/no-unused-vars` declares `meta.fixable: 'code'` — and for
20
+ * an unused import it emits only a SUGGESTION, which `--fix` never applies.
21
+ * `eslint-plugin-sonarjs/unused-import` declares no fixer at all. Measured on
22
+ * `@typescript-eslint/eslint-plugin@8.65.0`, with both rules enabled alongside
23
+ * ours in a single `verifyAndFix`: the import survived. `meta.fixable` is a
24
+ * capability flag about the RULE, not a promise about any given report.
25
+ *
26
+ * Those rules abstain for a real reason — removing an import can change
27
+ * behaviour (`import './polyfill'`, modules with top-level effects), and a
28
+ * generic rule cannot prove otherwise. A rule that CREATED the orphan is in a
29
+ * strictly better position: it knows it just consumed the binding's last
30
+ * reference, and it knows the module, because its own config named it.
31
+ *
32
+ * ## Why the module list is closed, and hardcoded
33
+ *
34
+ * Every entry is a Node builtin that this pack already targets, and every one is
35
+ * side-effect-free with certainty decided here, at authoring time. The only
36
+ * general signal available instead would be package.json `sideEffects` — author
37
+ * declared, unverified, absent by default (and absence means "assume side
38
+ * effects"), sometimes a glob array rather than a boolean, requiring filesystem
39
+ * resolution per import inside a linter expected to be pure and fast, and it
40
+ * **does not apply to `node:` builtins at all**, which is the only case here.
41
+ * So: no `sideEffects` lookup, no I/O, no new dependency, and deliberately NOT
42
+ * a general `unused-import-no-side-effects` rule. For blanket cleanup outside
43
+ * this list, `eslint-plugin-unused-imports` already exists and already autofixes.
44
+ *
45
+ * Bare aliases (`path`, `os`, …) are listed beside their `node:`-prefixed
46
+ * spellings because the rules themselves treat the two as one module. Detecting
47
+ * `path.join()` from `import path from 'path'` and then declining to clean up
48
+ * after it would leave exactly the adopter's blocker in place for whichever
49
+ * spelling a file happened to use.
50
+ */
51
+
52
+ const REMOVABLE_MODULES = new Set([
53
+ 'node:path',
54
+ 'path',
55
+ 'node:os',
56
+ 'os',
57
+ 'node:fs',
58
+ 'fs',
59
+ 'node:fs/promises',
60
+ 'fs/promises',
61
+ 'node:child_process',
62
+ 'child_process',
63
+ ]);
64
+
65
+ const DEAD_UNSAFE_IMPORT = 'deadUnsafeImport';
66
+ const DEAD_UNSAFE_IMPORT_MESSAGE =
67
+ "'{{local}}' is no longer used — this rule's autofix rewrote the last call that " +
68
+ "referenced it. Remove the '{{module}}' import.";
69
+
70
+ /**
71
+ * Does this declaration bring in anything the type checker alone can see?
72
+ *
73
+ * A `type` binding has zero references by construction — scope analysis does not
74
+ * record type positions as references — so "nothing uses it" is not evidence of
75
+ * anything. Deleting one silently breaks every `typeof join` and every
76
+ * annotation that named it, and NEITHER `no-undef` nor `no-unused-vars` can see
77
+ * the damage, because the reference it broke was a type reference. Round 2 of
78
+ * this work learned that by shipping the deletion first.
79
+ */
80
+ function hasTypeOnlyBinding(node) {
81
+ return (
82
+ node.importKind === 'type' || node.specifiers.some((spec) => spec.importKind === 'type')
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Is every binding this declaration introduces now unreferenced?
88
+ *
89
+ * Whole declarations only. A partially-dead declaration (`import path, { sep }`
90
+ * with `sep` still live) needs comma surgery, which is where removal bugs live,
91
+ * and it is not the shape the adopter measured — so it is left alone and stays a
92
+ * visible `no-unused-vars` finding rather than a risky edit.
93
+ *
94
+ * Re-exports need no special guard: `export { path }` and `export default path`
95
+ * both register as references under espree AND `@typescript-eslint/parser`
96
+ * (measured, both parsers), so such a declaration is never dead here. Round 2
97
+ * added an explicit `isReExported` check for the SPECIFIER-removal path, where
98
+ * the reference count is not consulted at all; this path reads the count, so a
99
+ * second check would be a guard that can never fire.
100
+ */
101
+ function isDeadRemovableImport(sourceCode, node) {
102
+ if (!REMOVABLE_MODULES.has(node.source.value)) {
103
+ return false;
104
+ }
105
+ // A bare `import 'node:path';` declares no bindings, which would make "every
106
+ // binding is dead" vacuously true. Whether the module has side effects is
107
+ // beside the point — the author wrote a statement whose only possible purpose
108
+ // is its effect, and deleting it is an edit nobody asked for.
109
+ if (node.specifiers.length === 0 || hasTypeOnlyBinding(node)) {
110
+ return false;
111
+ }
112
+ // `every` over a non-empty list: the `specifiers.length === 0` bail above is
113
+ // what makes that safe, and it is the ONLY thing that does. A second
114
+ // `declared.length > 0` here would look like belt-and-braces and would in fact
115
+ // be a guard that can never fire — which mutation testing reports as an
116
+ // unguarded line, correctly, because deleting the real check leaves it green.
117
+ return sourceCode
118
+ .getDeclaredVariables(node)
119
+ .every((variable) => variable.references.length === 0);
120
+ }
121
+
122
+ /**
123
+ * Report — and remove — every import declaration this pass emptied out.
124
+ *
125
+ * Runs at `Program:exit`, over the SOURCE as it stands this pass. That ordering
126
+ * is the safety property, not an implementation detail: while a live reference
127
+ * survives in the text being linted, the binding is not dead and nothing is
128
+ * reported. The removal therefore lands on a later pass, after the rewrite that
129
+ * consumed the last reference — never speculatively alongside it.
130
+ *
131
+ * `migrated` gates the whole leg on the safe symbol being bound in the file, and
132
+ * is what keeps this a REPAIR leg rather than a general unused-import rule. It
133
+ * must be read from the SOURCE and never from a flag a `fix()` can flip: ESLint
134
+ * runs `fix()` for a suppressed problem before the `eslint-disable` filter
135
+ * discards it, so any mutable "did I add the import?" flag is already spent and
136
+ * lying by the time this runs.
137
+ *
138
+ * Its own report, with its own `fix`, deliberately — so the deletion appears in
139
+ * lint output and can be suppressed at the import line, rather than a rewrite
140
+ * quietly taking a declaration with it.
141
+ *
142
+ * Several rules in this pack can reach the same dead declaration in the same
143
+ * pass (a file using only `path.join` and `path.resolve` finishes owing nothing
144
+ * to `path`). They emit identical removals over an identical range, so ESLint
145
+ * applies one and drops the rest as overlapping. Measured with the three
146
+ * `safePath` rules enabled together over a file using all three: one
147
+ * `verifyAndFix`, output clean under `no-undef` and `no-unused-vars`, nothing
148
+ * left to report.
149
+ *
150
+ * In a check-only run that same file yields N identical messages, one per
151
+ * enabled rule. **Do not "fix" that by latching across rules.** These rule
152
+ * instances do share a module scope here, so a `WeakMap` keyed on `SourceCode`
153
+ * would dedupe them — and would reintroduce the exact trap round 2 was spent
154
+ * escaping. ESLint runs `fix()` for a suppressed problem BEFORE the
155
+ * `eslint-disable` filter discards it, so an `eslint-disable-next-line` naming
156
+ * whichever rule happened to win the latch would consume the file's only
157
+ * removal and then throw it away, leaving the import permanently undeletable and
158
+ * unreported. Duplicate messages on a file that is about to be fixed are the
159
+ * cheap failure; a silently stranded file is not.
160
+ *
161
+ * @param {object} context - ESLint rule context.
162
+ * @param {object} sourceCode - ESLint `SourceCode` for the file being linted.
163
+ * @param {object[]} importNodes - Unsafe-module `ImportDeclaration`s seen this pass.
164
+ * @param {boolean} migrated - Was the safe symbol already bound in the SOURCE?
165
+ */
166
+ function reportDeadUnsafeImports(context, sourceCode, importNodes, migrated) {
167
+ if (!migrated) {
168
+ return;
169
+ }
170
+ for (const node of importNodes) {
171
+ if (!isDeadRemovableImport(sourceCode, node)) {
172
+ continue;
173
+ }
174
+ context.report({
175
+ node,
176
+ messageId: DEAD_UNSAFE_IMPORT,
177
+ data: {
178
+ local: sourceCode
179
+ .getDeclaredVariables(node)
180
+ .map((variable) => variable.name)
181
+ .join("', '"),
182
+ module: node.source.value,
183
+ },
184
+ // `fixer.remove(node)` takes the declaration and leaves its newline, so a
185
+ // blank line remains where the import was. That is exactly what the
186
+ // specifier-removal leg in `path-function-rule-factory.cjs` has always
187
+ // done — its fixtures pin the leading `\n` — and matching it keeps one
188
+ // behaviour rather than two. Extending the range through a trailing
189
+ // whitespace-only remainder would tidy both, and should be done to both at
190
+ // once, once an adopter has measured whether their formatter cares.
191
+ fix: (fixer) => fixer.remove(node),
192
+ });
193
+ }
194
+ }
195
+
196
+ module.exports = {
197
+ DEAD_UNSAFE_IMPORT,
198
+ DEAD_UNSAFE_IMPORT_MESSAGE,
199
+ REMOVABLE_MODULES,
200
+ reportDeadUnsafeImports,
201
+ };