@vibe-agent-toolkit/utils 0.1.42-rc.1 → 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.
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 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
+
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,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
+ };
@@ -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,33 +202,81 @@ 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;
141
210
  let unsafeImportNode = null;
211
+ const unsafeImportNodes = [];
142
212
  let safeImportNode = null;
213
+ // A SET, because a namespace can be bound by a static import, a
214
+ // `require()`, or a dynamic `import()` — see `namespaceModuleOf`.
215
+ const unsafeNamespaceNames = new Set();
216
+ // Latches the REMOVAL only — never the insert. See `fix()` for why the
217
+ // two shared edits must be treated differently.
218
+ let unsafeImportRemoved = false;
143
219
 
144
220
  return {
145
221
  Program(node) {
146
222
  reportUnanchoredExemptEntries(context, node);
147
223
  },
148
224
 
225
+ 'Program:exit'() {
226
+ reportDeadUnsafeImports(context, sourceCode, unsafeImportNodes, safeBoundInSource);
227
+ },
228
+
149
229
  ImportDeclaration(node) {
150
- // Track unsafe module imports
151
230
  if (moduleVariants.includes(node.source.value)) {
152
231
  unsafeImportNode = node;
153
- for (const spec of node.specifiers) {
154
- if (spec.type === 'ImportSpecifier' && spec.imported.name === unsafeFn) {
155
- hasUnsafeImport = true;
156
- }
232
+ unsafeImportNodes.push(node);
233
+ hasUnsafeImport = hasUnsafeImport || importsName(node, unsafeFn);
234
+ const local = namespaceLocalName(node);
235
+ if (local) {
236
+ unsafeNamespaceNames.add(local);
157
237
  }
158
238
  }
159
-
160
- // Track safe module imports
161
239
  if (node.source.value === targetModule) {
162
240
  safeImportNode = node;
163
- for (const spec of node.specifiers) {
164
- if (spec.type === 'ImportSpecifier' && spec.imported.name === safeFn) {
165
- hasSafeImport = true;
166
- }
167
- }
241
+ hasSafeImport = hasSafeImport || importsName(node, safeFn);
242
+ }
243
+ },
244
+
245
+ // `const os = require('node:os')` / `const os = await import('node:os')`.
246
+ //
247
+ // Recorded by NAME, matching how the static-import receiver has always
248
+ // been tracked, so a declaration must precede its use — which is the
249
+ // normal shape and the only one either form appears in. Resolving the
250
+ // receiver through scope instead would also reject a shadowing rebind,
251
+ // but it would change detection parity on a population an adopter has
252
+ // already measured across 4,963 files, so it is not worth trading here.
253
+ //
254
+ // KNOWN RESIDUAL, measured: `dead-import.cjs` only removes an
255
+ // `ImportDeclaration`, so after the rewrite `const os = require('node:os')`
256
+ // and `const os = await import('node:os')` are both left behind as
257
+ // `'os' is assigned a value but never used` (the dynamic form also draws
258
+ // `sonarjs/no-dead-store`). Removing a VariableDeclaration is a wider edit
259
+ // than removing an import (multiple declarators, destructuring, an `await`
260
+ // inside control flow), so it is deliberately not done here. A static
261
+ // `import * as os` — the shape that actually appears at scale — is cleaned
262
+ // up. An adopter confirmed the residual 2-for-2 and measured **zero** files
263
+ // using either dynamic shape across 4,963 tracked sources, so the
264
+ // population this would serve is currently empty.
265
+ //
266
+ // If it is ever extended that far, note what makes the dynamic case
267
+ // different in kind: the leftover `await import('node:os')` STILL RUNS.
268
+ // The module is loaded and the promise awaited, and only the binding is
269
+ // dead — so deleting the statement removes an execution, not just a name.
270
+ // For these builtins that is unobservable, which is precisely why the
271
+ // module list is closed; the same edit against an arbitrary module would
272
+ // not be safe, and no `sideEffects` metadata could tell you so.
273
+ VariableDeclarator(node) {
274
+ if (node.id.type !== 'Identifier') {
275
+ return;
276
+ }
277
+ const source = namespaceModuleOf(node.init);
278
+ if (source !== null && moduleVariants.includes(source)) {
279
+ unsafeNamespaceNames.add(node.id.name);
168
280
  }
169
281
  },
170
282
 
@@ -176,10 +288,23 @@ module.exports = function createNoUnsafeRule(config) {
176
288
  isUnsafeCall = true;
177
289
  }
178
290
 
179
- // Check for member expression: obj.unsafeFn()
291
+ // Check for member expression: os.tmpdir()
292
+ //
293
+ // The RECEIVER must be the unsafe module's own namespace binding.
294
+ // Matching on the property name alone made `env.tmpdir()` — any
295
+ // object at all with a same-named method — an `os.tmpdir()` finding.
296
+ // That was survivable while the fixer rewrote only the property
297
+ // (`env.normalizedTmpdir()` fails to compile, so the false positive
298
+ // announced itself); once the whole callee is replaced it becomes
299
+ // `normalizedTmpdir()`, which compiles, type-checks, passes
300
+ // `no-undef`, and silently calls a different function with the
301
+ // receiver discarded. A false positive that produces WORKING code is
302
+ // strictly the more dangerous kind.
180
303
  if (
181
304
  checkMemberExpression &&
182
305
  node.callee.type === 'MemberExpression' &&
306
+ node.callee.object.type === 'Identifier' &&
307
+ unsafeNamespaceNames.has(node.callee.object.name) &&
183
308
  node.callee.property.name === unsafeFn
184
309
  ) {
185
310
  isUnsafeCall = true;
@@ -196,39 +321,83 @@ module.exports = function createNoUnsafeRule(config) {
196
321
  fix(fixer) {
197
322
  const fixes = [];
198
323
 
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
- }
324
+ // Replace the WHOLE callee, member expression or not.
325
+ //
326
+ // Rewriting only the property turned `os.tmpdir()` into
327
+ // `os.normalizedTmpdir()` — a method that does not exist on the
328
+ // `node:os` namespace. The replacement is a free function from
329
+ // OUR package, and the fixer imported it correctly; it just left
330
+ // the call reaching for it through the wrong object. Silent, like
331
+ // the overlap bug below: lint went green (the rule no longer sees
332
+ // `tmpdir`), and it is a dangling MEMBER rather than a dangling
333
+ // identifier, so `no-undef` cannot see it either. `tsc` can.
334
+ fixes.push(fixer.replaceText(node.callee, safeFn));
207
335
 
208
- // Add import if needed
336
+ // Add import if needed — on EVERY report, deliberately.
337
+ //
338
+ // The sister factory emits this once per file, because there a
339
+ // report that edits both the import and its own call site spans
340
+ // everything between them, N reports leave N nested ranges, and
341
+ // ESLint keeps one: the defect measured at 146 broken files.
342
+ //
343
+ // That guard does not belong here, and briefly having it was a
344
+ // mistake worth recording. These rules do NOT key detection on
345
+ // the import — `node.callee.name === unsafeFn` is true whether or
346
+ // not the specifier survives — and `hasSafeImport` is reseeded
347
+ // from scope each pass, so pass 2 always finished the job anyway.
348
+ // An adversarial run confirmed the guard changed no output at 4,
349
+ // 40 or 75 call sites. What it DID change was the failure mode:
350
+ // ESLint runs `fix()` for a suppressed problem before the
351
+ // `eslint-disable` filter discards it, so one disable comment on
352
+ // the first call site spent the once-per-file edit and stranded
353
+ // the file with calls the import no longer backs.
354
+ //
355
+ // Every report carrying its own import edit costs a pass and buys
356
+ // a fix that is correct on its own — including when applied alone
357
+ // from an editor's "fix this problem".
209
358
  if (!hasSafeImport) {
210
359
  if (safeImportNode) {
211
360
  // Add to existing safe module import
212
361
  const lastSpecifier = safeImportNode.specifiers.at(-1);
213
362
  fixes.push(fixer.insertTextAfter(lastSpecifier, `, ${safeFn}`));
214
363
  } else {
215
- // Create new import after unsafe import or at the top
364
+ // Land next to the imports, never after arbitrary code
365
+ // `insertTextAfter(body[0])` on a file whose first statement
366
+ // is a `const` welds the declaration onto the end of it.
216
367
  const targetNode = unsafeImportNode || sourceCode.ast.body[0];
217
- const newImport = `import { ${safeFn} } from '${targetModule}';\n`;
218
- fixes.push(fixer.insertTextAfter(targetNode, newImport));
368
+ const declaration = `import { ${safeFn} } from '${targetModule}';`;
369
+ fixes.push(
370
+ targetNode.type === 'ImportDeclaration'
371
+ ? fixer.insertTextAfter(targetNode, `\n${declaration}`)
372
+ : insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`),
373
+ );
219
374
  }
220
375
  }
221
376
 
222
- // Remove unsafe import if it's the only specifier
223
- if (hasUnsafeImport && unsafeImportNode) {
377
+ // Remove the unsafe import LATCHED, unlike the insert above.
378
+ //
379
+ // The asymmetry is the whole design. An insert is safe to repeat
380
+ // (identical text, identical anchor, ESLint drops the duplicate)
381
+ // and repeating it is what keeps each report's fix correct on its
382
+ // own. A REMOVAL is not: if every report removes the specifier,
383
+ // one of those removals lands even when the report that would
384
+ // have rewritten the matching call was suppressed — and the
385
+ // suppressed call is left calling an identifier the import no
386
+ // longer provides. Measured: `tmpdir` undefined, permanently.
387
+ //
388
+ // Latched, the discarded first report simply takes the removal
389
+ // with it, and the worst case is an unused import that
390
+ // `no-unused-vars` will point at. A lint finding, not a crash.
391
+ if (hasUnsafeImport && unsafeImportNode && !unsafeImportRemoved) {
224
392
  const unsafeSpecs = filterUnsafeSpecifiers(unsafeImportNode, unsafeFn);
225
393
  if (unsafeImportNode.specifiers.length === 1 && unsafeSpecs.length === 1) {
226
394
  // Remove entire import
227
395
  fixes.push(fixer.remove(unsafeImportNode));
228
396
  } else if (unsafeSpecs.length > 0) {
229
397
  // Remove just the unsafe specifier
230
- fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeImportNode, unsafeSpecs));
398
+ fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs));
231
399
  }
400
+ unsafeImportRemoved = true;
232
401
  }
233
402
 
234
403
  return fixes;
@@ -13,14 +13,21 @@
13
13
  * const normalized = toForwardSlash(relativePath);
14
14
  */
15
15
 
16
+ const {
17
+ DEAD_UNSAFE_IMPORT,
18
+ DEAD_UNSAFE_IMPORT_MESSAGE,
19
+ reportDeadUnsafeImports,
20
+ } = require('./dead-import.cjs');
16
21
  const {
17
22
  SAFE_MODULE_ONLY_SCHEMA,
18
23
  SAFE_PATH_MODULE,
24
+ insertAboveWithComments,
19
25
  isNameAlreadyBound,
20
26
  resolveSafeModule,
21
27
  } = require('./safe-import.cjs');
22
28
 
23
29
  const SAFE_FN = 'toForwardSlash';
30
+ const PATH_MODULES = new Set(['node:path', 'path']);
24
31
 
25
32
  module.exports = {
26
33
  meta: {
@@ -35,6 +42,7 @@ module.exports = {
35
42
  useToForwardSlash:
36
43
  'Use toForwardSlash() from {{safeModule}} instead of manual path normalization. ' +
37
44
  'Manual normalization is error-prone and less maintainable.',
45
+ [DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
38
46
  },
39
47
  schema: [SAFE_MODULE_ONLY_SCHEMA],
40
48
  },
@@ -46,10 +54,24 @@ module.exports = {
46
54
  // barrel must have the call rewritten WITHOUT gaining a second binding of
47
55
  // the same name — that is a SyntaxError. See `safe-import.cjs`.
48
56
  let hasToForwardSlashImport = isNameAlreadyBound(sourceCode, SAFE_FN);
57
+ // Never mutated — the dead-import leg must not be armed by a flag that a
58
+ // suppressed report's `fix()` can spend. See `dead-import.cjs`.
59
+ const safeBoundInSource = hasToForwardSlashImport;
49
60
  let utilsImportNode = null;
61
+ // `path.sep` is the last `path.*` reference in plenty of files, and
62
+ // `toForwardSlash(raw)` consumes it — leaving the same dead `node:path`
63
+ // binding the `safePath` rules used to leave.
64
+ const pathImportNodes = [];
50
65
 
51
66
  return {
67
+ 'Program:exit'() {
68
+ reportDeadUnsafeImports(context, sourceCode, pathImportNodes, safeBoundInSource);
69
+ },
70
+
52
71
  ImportDeclaration(node) {
72
+ if (PATH_MODULES.has(node.source.value)) {
73
+ pathImportNodes.push(node);
74
+ }
53
75
  if (node.source.value === targetModule) {
54
76
  utilsImportNode = node;
55
77
  for (const spec of node.specifiers) {
@@ -110,12 +132,21 @@ module.exports = {
110
132
  // Create new import at the top
111
133
  const firstNode = sourceCode.ast.body[0];
112
134
  const newImport = `import { ${SAFE_FN} } from '${targetModule}';\n`;
113
- fixes.push(fixer.insertTextBefore(firstNode, newImport));
135
+ fixes.push(insertAboveWithComments(fixer, sourceCode, firstNode, newImport));
114
136
  }
115
- // Multiple reports in one pass share this closure; without
116
- // this, a second occurrence in the same file inserts the
117
- // import a second time.
118
- hasToForwardSlashImport = true;
137
+ // NOT latched. The comment here used to claim that without a
138
+ // `hasToForwardSlashImport = true` a second occurrence would
139
+ // insert the import twice; an adversarial run could not
140
+ // reproduce that at any occurrence count. It cannot happen:
141
+ // both reports insert identical text at the identical anchor,
142
+ // so the ranges coincide and ESLint applies one and drops the
143
+ // other as overlapping.
144
+ //
145
+ // Latching it is not free, either. ESLint runs `fix()` for a
146
+ // SUPPRESSED problem before the `eslint-disable` filter
147
+ // discards it, so the first report could spend the flag and
148
+ // then be thrown away — leaving later occurrences rewritten
149
+ // to a `toForwardSlash` nothing imports.
119
150
  }
120
151
 
121
152
  return fixes;