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

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 (52) hide show
  1. package/README.md +19 -3
  2. package/dist/file-crawler.d.ts.map +1 -1
  3. package/dist/file-crawler.js +88 -2
  4. package/dist/file-crawler.js.map +1 -1
  5. package/dist/fs-utils.d.ts +389 -30
  6. package/dist/fs-utils.d.ts.map +1 -1
  7. package/dist/fs-utils.js +425 -56
  8. package/dist/fs-utils.js.map +1 -1
  9. package/dist/fs.d.ts +2 -1
  10. package/dist/fs.d.ts.map +1 -1
  11. package/dist/fs.js +7 -1
  12. package/dist/fs.js.map +1 -1
  13. package/dist/git-root-cache.d.ts +44 -0
  14. package/dist/git-root-cache.d.ts.map +1 -0
  15. package/dist/git-root-cache.js +68 -0
  16. package/dist/git-root-cache.js.map +1 -0
  17. package/dist/git-utils.d.ts +11 -0
  18. package/dist/git-utils.d.ts.map +1 -1
  19. package/dist/git-utils.js +28 -8
  20. package/dist/git-utils.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +37 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/numeric-args.d.ts +24 -0
  26. package/dist/numeric-args.d.ts.map +1 -0
  27. package/dist/numeric-args.js +37 -0
  28. package/dist/numeric-args.js.map +1 -0
  29. package/dist/path-core.d.ts +30 -0
  30. package/dist/path-core.d.ts.map +1 -1
  31. package/dist/path-core.js +32 -0
  32. package/dist/path-core.js.map +1 -1
  33. package/dist/path.d.ts +1 -1
  34. package/dist/path.d.ts.map +1 -1
  35. package/dist/path.js +1 -1
  36. package/dist/path.js.map +1 -1
  37. package/dist/project-utils.d.ts +7 -1
  38. package/dist/project-utils.d.ts.map +1 -1
  39. package/dist/project-utils.js +9 -1
  40. package/dist/project-utils.js.map +1 -1
  41. package/dist/test-helpers.d.ts +16 -0
  42. package/dist/test-helpers.d.ts.map +1 -1
  43. package/dist/test-helpers.js +28 -1
  44. package/dist/test-helpers.js.map +1 -1
  45. package/eslint/README.md +27 -1
  46. package/eslint/rules/dead-import.cjs +251 -0
  47. package/eslint/rules/eslint-rule-factory.cjs +213 -29
  48. package/eslint/rules/no-manual-path-normalize.cjs +59 -7
  49. package/eslint/rules/path-function-rule-factory.cjs +314 -34
  50. package/eslint/rules/prefer-startswith-over-regex.cjs +209 -40
  51. package/eslint/rules/safe-import.cjs +23 -0
  52. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.js","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"test-helpers.js","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"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"}
package/eslint/README.md CHANGED
@@ -112,6 +112,8 @@ Two ways your target can be wrong, which surface differently: `ERR_MODULE_NOT_FO
112
112
  | `no-child-process-execSync` | `child_process.execSync()` | `safeExecSync()` | `/process` | ✓ | `error` |
113
113
  | `no-unix-shell-commands` | `tar`, `grep`, `rm`, `echo`, … spawned directly | Node APIs, or a portable script fixture | — | | `error` |
114
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
+
115
117
  ### URLs and dynamic imports
116
118
 
117
119
  | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
@@ -124,7 +126,7 @@ Two ways your target can be wrong, which surface differently: `ERR_MODULE_NOT_FO
124
126
 
125
127
  | Rule | Bans | Use instead | Subpath | Fix | `recommended` |
126
128
  |---|---|---|---|---|---|
127
- | `prefer-startswith-over-regex` | `/^foo/.test(s)` | `s.startsWith('foo')` | — | | `error` |
129
+ | `prefer-startswith-over-regex` | `/^foo/.test(s)`, `` /^\*glob/.test(s) ``, `const RE = /^foo/; RE.test(s)` | `s.startsWith('foo')` | — | | `error` |
128
130
  | `no-test-scoped-functions` | helper functions declared inside `describe`/`it` | module scope | — | | — |
129
131
  | `require-justified-skip` | unannotated `it.skip`/`it.todo`, tautological assertions, empty test bodies | a `SKIP(#123): reason` annotation, or a real assertion | — | | — |
130
132
 
@@ -176,6 +178,30 @@ Raise all three to `error` once the backlog is clear. That is what this repo doe
176
178
 
177
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.
178
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 this rule migrated** — the safe symbol must be bound AND the rule's own replacement must actually be called in the file (`safePath.join(…)`, `normalizedTmpdir()`, `toForwardSlash(…)`). "The safe symbol is in scope" alone is not evidence: `safePath` arrives for reasons that have nothing to do with a call this pack consumed, and an import that was dead already — dead before the pack ever ran — was then deleted as though this fixer had orphaned it. 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
+
179
205
  ## Why custom rules
180
206
 
181
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.
@@ -0,0 +1,251 @@
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
+ // States what the rule can actually observe, and no more. The earlier wording —
67
+ // "this rule's autofix rewrote the last call that referenced it" — asserted a
68
+ // CAUSE, and the leg cannot see causes: by the time a binding reads as dead the
69
+ // rewrite that killed it is a pass in the past and left no trace naming which
70
+ // import it drew on. On a file with two imports of the same module, one of them
71
+ // dead since before the pack ever ran, that sentence was simply false about one
72
+ // of the two. See `reportDeadUnsafeImports` for what the gate does prove.
73
+ const DEAD_UNSAFE_IMPORT_MESSAGE =
74
+ "'{{local}}' has no remaining references, and this file already calls the safe " +
75
+ "replacement this rule's autofix writes. Remove the '{{module}}' import.";
76
+
77
+ /**
78
+ * Does this declaration bring in anything the type checker alone can see?
79
+ *
80
+ * A `type` binding has zero references by construction — scope analysis does not
81
+ * record type positions as references — so "nothing uses it" is not evidence of
82
+ * anything. Deleting one silently breaks every `typeof join` and every
83
+ * annotation that named it, and NEITHER `no-undef` nor `no-unused-vars` can see
84
+ * the damage, because the reference it broke was a type reference. Round 2 of
85
+ * this work learned that by shipping the deletion first.
86
+ */
87
+ function hasTypeOnlyBinding(node) {
88
+ return (
89
+ node.importKind === 'type' || node.specifiers.some((spec) => spec.importKind === 'type')
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Is every binding this declaration introduces now unreferenced?
95
+ *
96
+ * Whole declarations only. A partially-dead declaration (`import path, { sep }`
97
+ * with `sep` still live) needs comma surgery, which is where removal bugs live,
98
+ * and it is not the shape the adopter measured — so it is left alone and stays a
99
+ * visible `no-unused-vars` finding rather than a risky edit.
100
+ *
101
+ * Re-exports need no special guard: `export { path }` and `export default path`
102
+ * both register as references under espree AND `@typescript-eslint/parser`
103
+ * (measured, both parsers), so such a declaration is never dead here. Round 2
104
+ * added an explicit `isReExported` check for the SPECIFIER-removal path, where
105
+ * the reference count is not consulted at all; this path reads the count, so a
106
+ * second check would be a guard that can never fire.
107
+ */
108
+ function isDeadRemovableImport(sourceCode, node) {
109
+ if (!REMOVABLE_MODULES.has(node.source.value)) {
110
+ return false;
111
+ }
112
+ // A bare `import 'node:path';` declares no bindings, which would make "every
113
+ // binding is dead" vacuously true. Whether the module has side effects is
114
+ // beside the point — the author wrote a statement whose only possible purpose
115
+ // is its effect, and deleting it is an edit nobody asked for.
116
+ if (node.specifiers.length === 0 || hasTypeOnlyBinding(node)) {
117
+ return false;
118
+ }
119
+ // `every` over a non-empty list: the `specifiers.length === 0` bail above is
120
+ // what makes that safe, and it is the ONLY thing that does. A second
121
+ // `declared.length > 0` here would look like belt-and-braces and would in fact
122
+ // be a guard that can never fire — which mutation testing reports as an
123
+ // unguarded line, correctly, because deleting the real check leaves it green.
124
+ return sourceCode
125
+ .getDeclaredVariables(node)
126
+ .every((variable) => variable.references.length === 0);
127
+ }
128
+
129
+ /**
130
+ * Report — and remove — every import declaration this pass emptied out.
131
+ *
132
+ * Runs at `Program:exit`, over the SOURCE as it stands this pass. That ordering
133
+ * is the safety property, not an implementation detail: while a live reference
134
+ * survives in the text being linted, the binding is not dead and nothing is
135
+ * reported. The removal therefore lands on a later pass, after the rewrite that
136
+ * consumed the last reference — never speculatively alongside it.
137
+ *
138
+ * ## The two-part gate, and the evidence it cannot get
139
+ *
140
+ * Both gates keep this a REPAIR leg rather than a general unused-import rule,
141
+ * and both must be read from the SOURCE, never from a flag a `fix()` can flip:
142
+ * ESLint runs `fix()` for a suppressed problem before the `eslint-disable`
143
+ * filter discards it, so any mutable "did I add the import?" flag is already
144
+ * spent and lying by the time this runs.
145
+ *
146
+ * - `safeBoundInSource` — the safe symbol is in scope at all.
147
+ * - `replacementCalled` — THIS rule's own replacement is actually CALLED in this
148
+ * file (`safePath.join(…)`, `normalizedTmpdir()`, `toForwardSlash(…)`), which
149
+ * is the text its fixer writes and nothing else does.
150
+ *
151
+ * The first alone was the whole gate once, and it let an unrelated coincidence
152
+ * arm the leg: `safePath` reaches scope for reasons that have nothing to do with
153
+ * a call this pack consumed, and any `node:path` import that happened to be dead
154
+ * already — dead before the pack ever ran, for somebody else's reason — was then
155
+ * deleted under a message claiming this fixer had orphaned it. That is the
156
+ * general unused-import rule the module docstring above declines to be, reached
157
+ * by accident. The second gate closes it: no call to this rule's replacement
158
+ * means this rule rewrote nothing here, whatever else is in scope.
159
+ *
160
+ * It does NOT get down to the individual declaration, and it cannot. Per-import
161
+ * evidence has to name WHICH import a consumed reference belonged to, and the
162
+ * only pass that knows is the one doing the rewrite — where every reference is
163
+ * still live and nothing is dead to report. Removing speculatively in that pass
164
+ * instead is the trap the rest of this pack is built around: a suppressed call
165
+ * report still runs its `fix()`, so the removal lands while the call it was
166
+ * paired with survives, and the file is left with a dangling reference (146 of
167
+ * them, measured, in the defect this file exists to prevent). Latching state
168
+ * across passes to carry the answer forward is the rejected `WeakMap` approach
169
+ * for the same reason, one layer worse. So a file holding two imports of one
170
+ * module, one of them dead all along, still gets both removed and one true
171
+ * message plus one that merely describes the file rather than the history. The
172
+ * message is worded to stay true of both; the residual is a redundant deletion,
173
+ * never a broken one.
174
+ *
175
+ * Being over-strict here is cheap by construction: a gate that declines leaves
176
+ * an unused import, which is a lint finding a human reads. Only firing wrongly
177
+ * costs anything, and only the CALL rewrite can break a file.
178
+ *
179
+ * Its own report, with its own `fix`, deliberately — so the deletion appears in
180
+ * lint output and can be suppressed at the import line, rather than a rewrite
181
+ * quietly taking a declaration with it.
182
+ *
183
+ * Several rules in this pack can reach the same dead declaration in the same
184
+ * pass (a file using only `path.join` and `path.resolve` finishes owing nothing
185
+ * to `path`). They emit identical removals over an identical range, so ESLint
186
+ * applies one and drops the rest as overlapping. Measured with the three
187
+ * `safePath` rules enabled together over a file using all three: one
188
+ * `verifyAndFix`, output clean under `no-undef` and `no-unused-vars`, nothing
189
+ * left to report.
190
+ *
191
+ * In a check-only run that same file yields N identical messages, one per
192
+ * enabled rule. **Do not "fix" that by latching across rules.** These rule
193
+ * instances do share a module scope here, so a `WeakMap` keyed on `SourceCode`
194
+ * would dedupe them — and would reintroduce the exact trap round 2 was spent
195
+ * escaping. ESLint runs `fix()` for a suppressed problem BEFORE the
196
+ * `eslint-disable` filter discards it, so an `eslint-disable-next-line` naming
197
+ * whichever rule happened to win the latch would consume the file's only
198
+ * removal and then throw it away, leaving the import permanently undeletable and
199
+ * unreported. Duplicate messages on a file that is about to be fixed are the
200
+ * cheap failure; a silently stranded file is not.
201
+ *
202
+ * @param {object} context - ESLint rule context.
203
+ * @param {object} sourceCode - ESLint `SourceCode` for the file being linted.
204
+ * @param {object[]} importNodes - Unsafe-module `ImportDeclaration`s seen this pass.
205
+ * @param {boolean} safeBoundInSource - Was the safe symbol already bound in the SOURCE?
206
+ * @param {boolean} replacementCalled - Does the SOURCE call this rule's own safe
207
+ * replacement? Both are required, and both are the caller's to compute from
208
+ * source — see the gate discussion above for why neither may be defaulted.
209
+ */
210
+ function reportDeadUnsafeImports(
211
+ context,
212
+ sourceCode,
213
+ importNodes,
214
+ safeBoundInSource,
215
+ replacementCalled,
216
+ ) {
217
+ if (!safeBoundInSource || !replacementCalled) {
218
+ return;
219
+ }
220
+ for (const node of importNodes) {
221
+ if (!isDeadRemovableImport(sourceCode, node)) {
222
+ continue;
223
+ }
224
+ context.report({
225
+ node,
226
+ messageId: DEAD_UNSAFE_IMPORT,
227
+ data: {
228
+ local: sourceCode
229
+ .getDeclaredVariables(node)
230
+ .map((variable) => variable.name)
231
+ .join("', '"),
232
+ module: node.source.value,
233
+ },
234
+ // `fixer.remove(node)` takes the declaration and leaves its newline, so a
235
+ // blank line remains where the import was. That is exactly what the
236
+ // specifier-removal leg in `path-function-rule-factory.cjs` has always
237
+ // done — its fixtures pin the leading `\n` — and matching it keeps one
238
+ // behaviour rather than two. Extending the range through a trailing
239
+ // whitespace-only remainder would tidy both, and should be done to both at
240
+ // once, once an adopter has measured whether their formatter cares.
241
+ fix: (fixer) => fixer.remove(node),
242
+ });
243
+ }
244
+ }
245
+
246
+ module.exports = {
247
+ DEAD_UNSAFE_IMPORT,
248
+ DEAD_UNSAFE_IMPORT_MESSAGE,
249
+ REMOVABLE_MODULES,
250
+ reportDeadUnsafeImports,
251
+ };
@@ -35,6 +35,11 @@
35
35
  * // '@vibe-agent-toolkit/no-os-tmpdir': ['error', { exemptFiles: ['src/paths.ts'] }]
36
36
  */
37
37
 
38
+ const {
39
+ DEAD_UNSAFE_IMPORT,
40
+ DEAD_UNSAFE_IMPORT_MESSAGE,
41
+ reportDeadUnsafeImports,
42
+ } = require('./dead-import.cjs');
38
43
  const {
39
44
  UNANCHORED_EXEMPT_FILE,
40
45
  UNANCHORED_EXEMPT_MESSAGE,
@@ -43,10 +48,68 @@ const {
43
48
  } = require('./exempt-path-matcher.cjs');
44
49
  const {
45
50
  EXEMPT_AND_SAFE_MODULE_SCHEMA,
51
+ insertAboveWithComments,
46
52
  isNameAlreadyBound,
47
53
  resolveSafeModule,
48
54
  } = require('./safe-import.cjs');
49
55
 
56
+ /** Does this declaration bring in `name` as a named specifier? */
57
+ function importsName(importNode, name) {
58
+ return importNode.specifiers.some(
59
+ (spec) => spec.type === 'ImportSpecifier' && spec.imported.name === name,
60
+ );
61
+ }
62
+
63
+ /**
64
+ * The local name of `import os from 'node:os'` / `import * as os from 'node:os'`.
65
+ *
66
+ * Without it the member-expression check has no receiver to compare against,
67
+ * and matching on the property name alone turns every `env.tmpdir()` into an
68
+ * `os.tmpdir()` finding.
69
+ */
70
+ function namespaceLocalName(importNode) {
71
+ const spec = importNode.specifiers.find(
72
+ (candidate) =>
73
+ candidate.type === 'ImportDefaultSpecifier' || candidate.type === 'ImportNamespaceSpecifier',
74
+ );
75
+ return spec ? spec.local.name : null;
76
+ }
77
+
78
+ /**
79
+ * The module specifier of `require('x')`, `import('x')` or `await import('x')`.
80
+ *
81
+ * A static `import * as os` is not the only way to end up holding the `node:os`
82
+ * namespace, and the fix does not care which way it happened — the whole callee
83
+ * is replaced by a free function, so `os.tmpdir()` becomes `normalizedTmpdir()`
84
+ * whatever bound `os`.
85
+ *
86
+ * This is NOT a return to rc.1, which matched any receiver at all and so
87
+ * "detected" these shapes only as a side effect of the defect that also produced
88
+ * `os.normalizedTmpdir()`. The receiver check stays; this widens what counts as
89
+ * evidence that the receiver IS the module's namespace, and nothing else.
90
+ *
91
+ * @param {object} [init] - The initialiser of a variable declarator.
92
+ * @returns {string|null} The literal module name, or null.
93
+ */
94
+ function namespaceModuleOf(init) {
95
+ const expr = init?.type === 'AwaitExpression' ? init.argument : init;
96
+ if (!expr) {
97
+ return null;
98
+ }
99
+ const isDynamicImport = expr.type === 'ImportExpression';
100
+ const isRequire =
101
+ expr.type === 'CallExpression' &&
102
+ expr.callee.type === 'Identifier' &&
103
+ expr.callee.name === 'require';
104
+ if (!isDynamicImport && !isRequire) {
105
+ return null;
106
+ }
107
+ // `ImportExpression.source` / the sole `require` argument. A computed
108
+ // specifier names no module we can check, so it binds nothing we may rewrite.
109
+ const source = isDynamicImport ? expr.source : expr.arguments[0];
110
+ return source?.type === 'Literal' && typeof source.value === 'string' ? source.value : null;
111
+ }
112
+
50
113
  /**
51
114
  * Helper function to filter unsafe import specifiers
52
115
  * Extracted to reduce nesting depth for code quality
@@ -59,7 +122,7 @@ function filterUnsafeSpecifiers(importNode, unsafeFn) {
59
122
  * Helper function to remove unsafe import specifiers
60
123
  * Extracted to reduce nesting depth for code quality
61
124
  */
62
- function removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeImportNode, unsafeSpecs) {
125
+ function removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs) {
63
126
  const fixes = [];
64
127
  for (const spec of unsafeSpecs) {
65
128
  const comma = sourceCode.getTokenAfter(spec);
@@ -110,6 +173,7 @@ module.exports = function createNoUnsafeRule(config) {
110
173
  schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
111
174
  messages: {
112
175
  noUnsafeOperation: message,
176
+ [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
113
177
  [UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
114
178
  },
115
179
  },
@@ -138,37 +202,100 @@ module.exports = function createNoUnsafeRule(config) {
138
202
  // rewritten but must NOT gain a second binding of the same name — that is
139
203
  // a SyntaxError, not a redundant import. See `safe-import.cjs`.
140
204
  let hasSafeImport = isNameAlreadyBound(sourceCode, safeFn);
205
+ // The SAME question, answered once and never mutated. `hasSafeImport`
206
+ // flips the moment a fix inserts the import, and the dead-import leg must
207
+ // not be armed by a flag a suppressed report can spend — ESLint runs
208
+ // `fix()` before the `eslint-disable` filter discards the problem.
209
+ const safeBoundInSource = hasSafeImport;
210
+ // The dead-import leg's OTHER gate: does this file actually call `safeFn`,
211
+ // the free function this fixer writes? "The safe symbol is in scope" alone
212
+ // let an unrelated coincidence arm the leg — see `dead-import.cjs`. Read
213
+ // from the source during traversal, never from a `fix()`.
214
+ let safeReplacementCalled = false;
141
215
  let unsafeImportNode = null;
216
+ const unsafeImportNodes = [];
142
217
  let safeImportNode = null;
218
+ // A SET, because a namespace can be bound by a static import, a
219
+ // `require()`, or a dynamic `import()` — see `namespaceModuleOf`.
220
+ const unsafeNamespaceNames = new Set();
221
+ // Latches the REMOVAL only — never the insert. See `fix()` for why the
222
+ // two shared edits must be treated differently.
223
+ let unsafeImportRemoved = false;
143
224
 
144
225
  return {
145
226
  Program(node) {
146
227
  reportUnanchoredExemptEntries(context, node);
147
228
  },
148
229
 
230
+ 'Program:exit'() {
231
+ reportDeadUnsafeImports(
232
+ context,
233
+ sourceCode,
234
+ unsafeImportNodes,
235
+ safeBoundInSource,
236
+ safeReplacementCalled,
237
+ );
238
+ },
239
+
149
240
  ImportDeclaration(node) {
150
- // Track unsafe module imports
151
241
  if (moduleVariants.includes(node.source.value)) {
152
242
  unsafeImportNode = node;
153
- for (const spec of node.specifiers) {
154
- if (spec.type === 'ImportSpecifier' && spec.imported.name === unsafeFn) {
155
- hasUnsafeImport = true;
156
- }
243
+ unsafeImportNodes.push(node);
244
+ hasUnsafeImport = hasUnsafeImport || importsName(node, unsafeFn);
245
+ const local = namespaceLocalName(node);
246
+ if (local) {
247
+ unsafeNamespaceNames.add(local);
157
248
  }
158
249
  }
159
-
160
- // Track safe module imports
161
250
  if (node.source.value === targetModule) {
162
251
  safeImportNode = node;
163
- for (const spec of node.specifiers) {
164
- if (spec.type === 'ImportSpecifier' && spec.imported.name === safeFn) {
165
- hasSafeImport = true;
166
- }
167
- }
252
+ hasSafeImport = hasSafeImport || importsName(node, safeFn);
253
+ }
254
+ },
255
+
256
+ // `const os = require('node:os')` / `const os = await import('node:os')`.
257
+ //
258
+ // Recorded by NAME, matching how the static-import receiver has always
259
+ // been tracked, so a declaration must precede its use — which is the
260
+ // normal shape and the only one either form appears in. Resolving the
261
+ // receiver through scope instead would also reject a shadowing rebind,
262
+ // but it would change detection parity on a population an adopter has
263
+ // already measured across 4,963 files, so it is not worth trading here.
264
+ //
265
+ // KNOWN RESIDUAL, measured: `dead-import.cjs` only removes an
266
+ // `ImportDeclaration`, so after the rewrite `const os = require('node:os')`
267
+ // and `const os = await import('node:os')` are both left behind as
268
+ // `'os' is assigned a value but never used` (the dynamic form also draws
269
+ // `sonarjs/no-dead-store`). Removing a VariableDeclaration is a wider edit
270
+ // than removing an import (multiple declarators, destructuring, an `await`
271
+ // inside control flow), so it is deliberately not done here. A static
272
+ // `import * as os` — the shape that actually appears at scale — is cleaned
273
+ // up. An adopter confirmed the residual 2-for-2 and measured **zero** files
274
+ // using either dynamic shape across 4,963 tracked sources, so the
275
+ // population this would serve is currently empty.
276
+ //
277
+ // If it is ever extended that far, note what makes the dynamic case
278
+ // different in kind: the leftover `await import('node:os')` STILL RUNS.
279
+ // The module is loaded and the promise awaited, and only the binding is
280
+ // dead — so deleting the statement removes an execution, not just a name.
281
+ // For these builtins that is unobservable, which is precisely why the
282
+ // module list is closed; the same edit against an arbitrary module would
283
+ // not be safe, and no `sideEffects` metadata could tell you so.
284
+ VariableDeclarator(node) {
285
+ if (node.id.type !== 'Identifier') {
286
+ return;
287
+ }
288
+ const source = namespaceModuleOf(node.init);
289
+ if (source !== null && moduleVariants.includes(source)) {
290
+ unsafeNamespaceNames.add(node.id.name);
168
291
  }
169
292
  },
170
293
 
171
294
  CallExpression(node) {
295
+ if (node.callee.type === 'Identifier' && node.callee.name === safeFn) {
296
+ safeReplacementCalled = true;
297
+ }
298
+
172
299
  let isUnsafeCall = false;
173
300
 
174
301
  // Check for direct function call: unsafeFn()
@@ -176,10 +303,23 @@ module.exports = function createNoUnsafeRule(config) {
176
303
  isUnsafeCall = true;
177
304
  }
178
305
 
179
- // Check for member expression: obj.unsafeFn()
306
+ // Check for member expression: os.tmpdir()
307
+ //
308
+ // The RECEIVER must be the unsafe module's own namespace binding.
309
+ // Matching on the property name alone made `env.tmpdir()` — any
310
+ // object at all with a same-named method — an `os.tmpdir()` finding.
311
+ // That was survivable while the fixer rewrote only the property
312
+ // (`env.normalizedTmpdir()` fails to compile, so the false positive
313
+ // announced itself); once the whole callee is replaced it becomes
314
+ // `normalizedTmpdir()`, which compiles, type-checks, passes
315
+ // `no-undef`, and silently calls a different function with the
316
+ // receiver discarded. A false positive that produces WORKING code is
317
+ // strictly the more dangerous kind.
180
318
  if (
181
319
  checkMemberExpression &&
182
320
  node.callee.type === 'MemberExpression' &&
321
+ node.callee.object.type === 'Identifier' &&
322
+ unsafeNamespaceNames.has(node.callee.object.name) &&
183
323
  node.callee.property.name === unsafeFn
184
324
  ) {
185
325
  isUnsafeCall = true;
@@ -196,39 +336,83 @@ module.exports = function createNoUnsafeRule(config) {
196
336
  fix(fixer) {
197
337
  const fixes = [];
198
338
 
199
- // Replace unsafe call with safe call
200
- if (node.callee.type === 'MemberExpression') {
201
- // For obj.method(), replace just the method name
202
- fixes.push(fixer.replaceText(node.callee.property, safeFn));
203
- } else {
204
- // For method(), replace the whole callee
205
- fixes.push(fixer.replaceText(node.callee, safeFn));
206
- }
339
+ // Replace the WHOLE callee, member expression or not.
340
+ //
341
+ // Rewriting only the property turned `os.tmpdir()` into
342
+ // `os.normalizedTmpdir()` — a method that does not exist on the
343
+ // `node:os` namespace. The replacement is a free function from
344
+ // OUR package, and the fixer imported it correctly; it just left
345
+ // the call reaching for it through the wrong object. Silent, like
346
+ // the overlap bug below: lint went green (the rule no longer sees
347
+ // `tmpdir`), and it is a dangling MEMBER rather than a dangling
348
+ // identifier, so `no-undef` cannot see it either. `tsc` can.
349
+ fixes.push(fixer.replaceText(node.callee, safeFn));
207
350
 
208
- // Add import if needed
351
+ // Add import if needed — on EVERY report, deliberately.
352
+ //
353
+ // The sister factory emits this once per file, because there a
354
+ // report that edits both the import and its own call site spans
355
+ // everything between them, N reports leave N nested ranges, and
356
+ // ESLint keeps one: the defect measured at 146 broken files.
357
+ //
358
+ // That guard does not belong here, and briefly having it was a
359
+ // mistake worth recording. These rules do NOT key detection on
360
+ // the import — `node.callee.name === unsafeFn` is true whether or
361
+ // not the specifier survives — and `hasSafeImport` is reseeded
362
+ // from scope each pass, so pass 2 always finished the job anyway.
363
+ // An adversarial run confirmed the guard changed no output at 4,
364
+ // 40 or 75 call sites. What it DID change was the failure mode:
365
+ // ESLint runs `fix()` for a suppressed problem before the
366
+ // `eslint-disable` filter discards it, so one disable comment on
367
+ // the first call site spent the once-per-file edit and stranded
368
+ // the file with calls the import no longer backs.
369
+ //
370
+ // Every report carrying its own import edit costs a pass and buys
371
+ // a fix that is correct on its own — including when applied alone
372
+ // from an editor's "fix this problem".
209
373
  if (!hasSafeImport) {
210
374
  if (safeImportNode) {
211
375
  // Add to existing safe module import
212
376
  const lastSpecifier = safeImportNode.specifiers.at(-1);
213
377
  fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${safeFn}`));
214
378
  } else {
215
- // Create new import after unsafe import or at the top
379
+ // Land next to the imports, never after arbitrary code
380
+ // `insertTextAfter(body[0])` on a file whose first statement
381
+ // is a `const` welds the declaration onto the end of it.
216
382
  const targetNode = unsafeImportNode || sourceCode.ast.body[0];
217
- const newImport = `import { ${safeFn} } from '${targetModule}';\n`;
218
- fixes.push(fixer.insertTextAfter(targetNode, newImport));
383
+ const declaration = `import { ${safeFn} } from '${targetModule}';`;
384
+ fixes.push(
385
+ targetNode.type === 'ImportDeclaration'
386
+ ? fixer.insertTextAfter(targetNode, `\n${declaration}`)
387
+ : insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`),
388
+ );
219
389
  }
220
390
  }
221
391
 
222
- // Remove unsafe import if it's the only specifier
223
- if (hasUnsafeImport && unsafeImportNode) {
392
+ // Remove the unsafe import LATCHED, unlike the insert above.
393
+ //
394
+ // The asymmetry is the whole design. An insert is safe to repeat
395
+ // (identical text, identical anchor, ESLint drops the duplicate)
396
+ // and repeating it is what keeps each report's fix correct on its
397
+ // own. A REMOVAL is not: if every report removes the specifier,
398
+ // one of those removals lands even when the report that would
399
+ // have rewritten the matching call was suppressed — and the
400
+ // suppressed call is left calling an identifier the import no
401
+ // longer provides. Measured: `tmpdir` undefined, permanently.
402
+ //
403
+ // Latched, the discarded first report simply takes the removal
404
+ // with it, and the worst case is an unused import that
405
+ // `no-unused-vars` will point at. A lint finding, not a crash.
406
+ if (hasUnsafeImport && unsafeImportNode && !unsafeImportRemoved) {
224
407
  const unsafeSpecs = filterUnsafeSpecifiers(unsafeImportNode, unsafeFn);
225
408
  if (unsafeImportNode.specifiers.length === 1 && unsafeSpecs.length === 1) {
226
409
  // Remove entire import
227
410
  fixes.push(fixer.remove(unsafeImportNode));
228
411
  } else if (unsafeSpecs.length > 0) {
229
412
  // Remove just the unsafe specifier
230
- fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeImportNode, unsafeSpecs));
413
+ fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs));
231
414
  }
415
+ unsafeImportRemoved = true;
232
416
  }
233
417
 
234
418
  return fixes;