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