@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.
- package/README.md +19 -3
- package/dist/file-crawler.d.ts.map +1 -1
- package/dist/file-crawler.js +88 -2
- package/dist/file-crawler.js.map +1 -1
- package/dist/fs-utils.d.ts +389 -30
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +425 -56
- package/dist/fs-utils.js.map +1 -1
- package/dist/fs.d.ts +2 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/git-root-cache.d.ts +44 -0
- package/dist/git-root-cache.d.ts.map +1 -0
- package/dist/git-root-cache.js +68 -0
- package/dist/git-root-cache.js.map +1 -0
- package/dist/git-utils.d.ts +11 -0
- package/dist/git-utils.d.ts.map +1 -1
- package/dist/git-utils.js +28 -8
- package/dist/git-utils.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -2
- package/dist/index.js.map +1 -1
- package/dist/numeric-args.d.ts +24 -0
- package/dist/numeric-args.d.ts.map +1 -0
- package/dist/numeric-args.js +37 -0
- package/dist/numeric-args.js.map +1 -0
- package/dist/path-core.d.ts +30 -0
- package/dist/path-core.d.ts.map +1 -1
- package/dist/path-core.js +32 -0
- package/dist/path-core.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +1 -1
- package/dist/path.js.map +1 -1
- package/dist/project-utils.d.ts +7 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +9 -1
- package/dist/project-utils.js.map +1 -1
- package/dist/test-helpers.d.ts +16 -0
- package/dist/test-helpers.d.ts.map +1 -1
- package/dist/test-helpers.js +28 -1
- package/dist/test-helpers.js.map +1 -1
- package/eslint/README.md +27 -1
- package/eslint/rules/dead-import.cjs +251 -0
- package/eslint/rules/eslint-rule-factory.cjs +213 -29
- package/eslint/rules/no-manual-path-normalize.cjs +59 -7
- package/eslint/rules/path-function-rule-factory.cjs +314 -34
- package/eslint/rules/prefer-startswith-over-regex.cjs +209 -40
- package/eslint/rules/safe-import.cjs +23 -0
- package/package.json +2 -2
package/dist/test-helpers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-helpers.js","sourceRoot":"","sources":["../src/test-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;
|
|
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,
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
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
|
|
218
|
-
fixes.push(
|
|
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
|
|
223
|
-
|
|
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,
|
|
413
|
+
fixes.push(...removeUnsafeImportSpecifiers(fixer, sourceCode, unsafeSpecs));
|
|
231
414
|
}
|
|
415
|
+
unsafeImportRemoved = true;
|
|
232
416
|
}
|
|
233
417
|
|
|
234
418
|
return fixes;
|