@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 +27 -1
- package/eslint/rules/dead-import.cjs +201 -0
- package/eslint/rules/eslint-rule-factory.cjs +198 -29
- package/eslint/rules/no-manual-path-normalize.cjs +36 -5
- package/eslint/rules/path-function-rule-factory.cjs +225 -24
- package/eslint/rules/prefer-startswith-over-regex.cjs +185 -39
- package/eslint/rules/safe-import.cjs +23 -0
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
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
|
|
218
|
-
fixes.push(
|
|
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
|
|
223
|
-
|
|
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,
|
|
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
|
|
135
|
+
fixes.push(insertAboveWithComments(fixer, sourceCode, firstNode, newImport));
|
|
114
136
|
}
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// import
|
|
118
|
-
|
|
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;
|