@vibe-agent-toolkit/utils 0.1.42-rc.1 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint/README.md +27 -1
- package/eslint/rules/dead-import.cjs +201 -0
- package/eslint/rules/eslint-rule-factory.cjs +198 -29
- package/eslint/rules/no-manual-path-normalize.cjs +36 -5
- package/eslint/rules/path-function-rule-factory.cjs +225 -24
- package/eslint/rules/prefer-startswith-over-regex.cjs +185 -39
- package/eslint/rules/safe-import.cjs +23 -0
- package/package.json +1 -1
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* subpath that owns `safePath`, NOT the barrel. See `safe-import.cjs`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
const {
|
|
13
|
+
DEAD_UNSAFE_IMPORT,
|
|
14
|
+
DEAD_UNSAFE_IMPORT_MESSAGE,
|
|
15
|
+
reportDeadUnsafeImports,
|
|
16
|
+
} = require('./dead-import.cjs');
|
|
12
17
|
const {
|
|
13
18
|
UNANCHORED_EXEMPT_FILE,
|
|
14
19
|
UNANCHORED_EXEMPT_MESSAGE,
|
|
@@ -18,6 +23,7 @@ const {
|
|
|
18
23
|
const {
|
|
19
24
|
EXEMPT_AND_SAFE_MODULE_SCHEMA,
|
|
20
25
|
SAFE_PATH_MODULE,
|
|
26
|
+
insertAboveWithComments,
|
|
21
27
|
isNameAlreadyBound,
|
|
22
28
|
resolveSafeModule,
|
|
23
29
|
} = require('./safe-import.cjs');
|
|
@@ -58,10 +64,31 @@ function removeSpecifier(fixer, sourceCode, importNode, spec) {
|
|
|
58
64
|
|
|
59
65
|
/**
|
|
60
66
|
* Track path module specifiers from an import declaration.
|
|
67
|
+
*
|
|
68
|
+
* Two specifier shapes are deliberately NOT tracked, because tracking them is
|
|
69
|
+
* what let the fixer delete them:
|
|
70
|
+
*
|
|
71
|
+
* - **Type-only** (`import { type join, … }` / `import type { join }`). The
|
|
72
|
+
* binding exists only for the type checker; there is no call to rewrite, and
|
|
73
|
+
* removing the specifier silently breaks every `typeof join` that referenced
|
|
74
|
+
* it. `no-undef` cannot see the damage — it is a TYPE reference.
|
|
75
|
+
* - **Aliased** (`import { join as pathJoin }`). The rule never reported
|
|
76
|
+
* `pathJoin(...)` in the first place — `classifyCall` matches on the callee's
|
|
77
|
+
* name — so tracking the specifier bought nothing and cost the whole import:
|
|
78
|
+
* an unrelated unbound `join(` elsewhere in the file made the fixer remove
|
|
79
|
+
* the alias, breaking every working `pathJoin` call site.
|
|
61
80
|
*/
|
|
62
81
|
function trackPathImport(node, unsafeFn, state) {
|
|
82
|
+
if (node.importKind === 'type') {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
63
85
|
for (const spec of node.specifiers) {
|
|
64
|
-
if (
|
|
86
|
+
if (
|
|
87
|
+
spec.type === 'ImportSpecifier' &&
|
|
88
|
+
spec.importKind !== 'type' &&
|
|
89
|
+
spec.imported.name === unsafeFn &&
|
|
90
|
+
spec.local.name === unsafeFn
|
|
91
|
+
) {
|
|
65
92
|
state.namedImportSpec = spec;
|
|
66
93
|
state.namedImportNode = node;
|
|
67
94
|
}
|
|
@@ -71,6 +98,24 @@ function trackPathImport(node, unsafeFn, state) {
|
|
|
71
98
|
}
|
|
72
99
|
}
|
|
73
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Is `name` re-exported by a bare `export { name }` in this file?
|
|
103
|
+
*
|
|
104
|
+
* Removing the import specifier then leaves the export naming nothing, and the
|
|
105
|
+
* result does not PARSE — `Export 'join' is not defined`. An autofix whose
|
|
106
|
+
* output cannot be parsed is the worst outcome available, so the specifier
|
|
107
|
+
* stays and the call sites are still rewritten. Whatever is left is a lint
|
|
108
|
+
* finding a human can read, not a broken file.
|
|
109
|
+
*/
|
|
110
|
+
function isReExported(sourceCode, name) {
|
|
111
|
+
return sourceCode.ast.body.some(
|
|
112
|
+
(node) =>
|
|
113
|
+
node.type === 'ExportNamedDeclaration' &&
|
|
114
|
+
!node.source &&
|
|
115
|
+
node.specifiers.some((spec) => spec.local?.name === name),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
74
119
|
/**
|
|
75
120
|
* Track safe module import from an import declaration.
|
|
76
121
|
*/
|
|
@@ -83,51 +128,183 @@ function trackSafeImport(node, state) {
|
|
|
83
128
|
}
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Is `name` resolvable from `node`'s scope outward — a parameter, a local, an
|
|
133
|
+
* import, or a configured global?
|
|
134
|
+
*
|
|
135
|
+
* Used only to decide whether a bare `join(...)` with no `node:path` import is
|
|
136
|
+
* OUR `join` or somebody else's. `import { join } from 'lodash'` binds the name
|
|
137
|
+
* and is not our business; an unbound `join` is a ReferenceError waiting to
|
|
138
|
+
* happen, and — see `classifyCall` — is exactly what a half-applied autofix
|
|
139
|
+
* leaves behind.
|
|
140
|
+
*/
|
|
141
|
+
function isIdentifierBound(sourceCode, node, name) {
|
|
142
|
+
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
|
|
143
|
+
if (scope.variables.some((variable) => variable.name === name)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
86
150
|
/**
|
|
87
151
|
* Check if a call expression is an unsafe path function call.
|
|
88
|
-
*
|
|
152
|
+
*
|
|
153
|
+
* Returns `{ isNamed }` — or `{ importOnly: true }` for a call that is already
|
|
154
|
+
* correct and merely missing its import — or null if not a match.
|
|
89
155
|
*/
|
|
90
|
-
function classifyCall(node, unsafeFn, state) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
state.namedImportSpec
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
function classifyCall(node, unsafeFn, state, sourceCode) {
|
|
157
|
+
const isMember = node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier';
|
|
158
|
+
|
|
159
|
+
// Direct call: join(...)
|
|
160
|
+
if (node.callee.type === 'Identifier' && node.callee.name === unsafeFn) {
|
|
161
|
+
if (state.namedImportSpec) {
|
|
162
|
+
return { isNamed: true };
|
|
163
|
+
}
|
|
164
|
+
// REPAIR LEG. Keying detection on "did I see the import?" made this rule
|
|
165
|
+
// stop reporting the moment a fix removed the specifier, so a partial
|
|
166
|
+
// `--fix` reached a stable fixpoint over source that no longer compiles and
|
|
167
|
+
// exited clean.
|
|
168
|
+
//
|
|
169
|
+
// Gated on `safePath` already being bound, which is what makes it a repair
|
|
170
|
+
// rather than a second, sloppier detector. An unbound `join` is NOT reliably
|
|
171
|
+
// our `join`: ESLint scope analysis does not bind `declare global { function
|
|
172
|
+
// join() }`, and it cannot see an ambient global from a `globals.d.ts`, an
|
|
173
|
+
// `@types` package, or a bundler — `resolve` and `relative` are entirely
|
|
174
|
+
// plausible as those. Requiring `safePath` in scope narrows this to the
|
|
175
|
+
// half-migrated file it exists to finish, where the name really was ours.
|
|
176
|
+
if (state.safePathBoundInSource && !isIdentifierBound(sourceCode, node.callee, unsafeFn)) {
|
|
177
|
+
return { isNamed: false };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
98
180
|
}
|
|
99
|
-
|
|
181
|
+
|
|
182
|
+
// Namespace call: path.join(...)
|
|
100
183
|
if (
|
|
101
|
-
|
|
184
|
+
isMember &&
|
|
102
185
|
node.callee.object.type === 'Identifier' &&
|
|
103
186
|
node.callee.object.name === state.defaultImportName &&
|
|
104
|
-
node.callee.property.type === 'Identifier' &&
|
|
105
187
|
node.callee.property.name === unsafeFn
|
|
106
188
|
) {
|
|
107
189
|
return { isNamed: false };
|
|
108
190
|
}
|
|
191
|
+
|
|
192
|
+
// REPAIR LEG, the other half: `safePath.join(...)` with no `safePath` in
|
|
193
|
+
// scope. This is what a partially-applied fix leaves — and without it, that
|
|
194
|
+
// state is PERMANENT rather than transient.
|
|
195
|
+
//
|
|
196
|
+
// ESLint runs `fix()` for a problem BEFORE the `eslint-disable` filter
|
|
197
|
+
// discards it, so a suppressed report on the first call site consumes the
|
|
198
|
+
// once-per-file import edit and then throws it away. Every other call is
|
|
199
|
+
// rewritten to `safePath.join`, nothing imports `safePath`, and no report
|
|
200
|
+
// survives to carry the import on any later pass. Recognising the orphaned
|
|
201
|
+
// call is what closes that loop; it costs one extra pass, and only in a file
|
|
202
|
+
// that is already broken.
|
|
203
|
+
if (
|
|
204
|
+
isMember &&
|
|
205
|
+
node.callee.object.type === 'Identifier' &&
|
|
206
|
+
node.callee.object.name === SAFE_OBJECT &&
|
|
207
|
+
node.callee.property.name === unsafeFn &&
|
|
208
|
+
!isIdentifierBound(sourceCode, node.callee.object, SAFE_OBJECT)
|
|
209
|
+
) {
|
|
210
|
+
return { importOnly: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
109
213
|
return null;
|
|
110
214
|
}
|
|
111
215
|
|
|
112
216
|
/**
|
|
113
217
|
* Build auto-fix for an unsafe path function call.
|
|
218
|
+
*
|
|
219
|
+
* ## Why the import edits are emitted at most ONCE per file
|
|
220
|
+
*
|
|
221
|
+
* ESLint merges the fixes one `fix()` yields into a SINGLE range spanning
|
|
222
|
+
* `min..max`, and applies only non-overlapping ranges per pass. A fix that
|
|
223
|
+
* touches both the import and its own call site therefore spans everything in
|
|
224
|
+
* between — so N such reports produce N nested ranges, ESLint keeps the
|
|
225
|
+
* shortest and DISCARDS THE REST.
|
|
226
|
+
*
|
|
227
|
+
* That is not an edge case, it is every file with more than one call site. The
|
|
228
|
+
* import edit landed, the other calls did not, and (before `classifyCall` grew
|
|
229
|
+
* its bare-call leg) the next pass could no longer see them because the
|
|
230
|
+
* specifier it keyed on was gone. `--fix` reached a stable fixpoint over source
|
|
231
|
+
* that does not compile and exited clean. An adopter measured 146 files left
|
|
232
|
+
* with a dangling reference across one sweep — worst single file, 75 call sites.
|
|
233
|
+
*
|
|
234
|
+
* So: the shared edits belong to the first report, and every later report emits
|
|
235
|
+
* a fix LOCAL to its own callee. Nothing overlaps, and one pass fixes the file.
|
|
236
|
+
* `no-manual-path-normalize.cjs` carries the same guard for the same reason.
|
|
237
|
+
*
|
|
238
|
+
* Only the FIRST report's fix is self-sufficient, and that is load-bearing:
|
|
239
|
+
* applying a later one ALONE — an editor's "fix this problem", or an
|
|
240
|
+
* `eslint-disable` on the first call site — rewrites the call without adding
|
|
241
|
+
* the import. ESLint runs `fix()` before the disable filter, so a suppressed
|
|
242
|
+
* report consumes the once-per-file edit and then discards it.
|
|
243
|
+
*
|
|
244
|
+
* That state is recoverable rather than permanent ONLY because `classifyCall`
|
|
245
|
+
* has a repair leg for an orphaned `safePath.join(...)`. Without it the file
|
|
246
|
+
* stays broken through every subsequent `--fix`, because no report is left to
|
|
247
|
+
* carry the import — measured, not reasoned about. An earlier draft of this
|
|
248
|
+
* comment asserted the recovery came free from `hasSafePathImport` being seeded
|
|
249
|
+
* from scope; that was wrong, and an adversarial run produced the stable broken
|
|
250
|
+
* fixpoint to prove it.
|
|
251
|
+
*
|
|
252
|
+
* The shared edits still cannot be hoisted onto their own report: removing
|
|
253
|
+
* `join` from the import while a suppressed `join(...)` call survives is the
|
|
254
|
+
* same broken output reached a different way. `exemptFiles` opts a whole file
|
|
255
|
+
* out.
|
|
114
256
|
*/
|
|
115
|
-
function
|
|
257
|
+
function importSafePath(fixer, sourceCode, state) {
|
|
258
|
+
if (state.safeImportNode) {
|
|
259
|
+
const lastSpec = state.safeImportNode.specifiers.at(-1);
|
|
260
|
+
return fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`);
|
|
261
|
+
}
|
|
262
|
+
const targetNode = state.namedImportNode || sourceCode.ast.body[0];
|
|
263
|
+
const declaration = `import { ${SAFE_OBJECT} } from '${state.safeModule}';`;
|
|
264
|
+
// Land the new import next to the imports, not after arbitrary code. A file
|
|
265
|
+
// reported only through a repair leg may have no path import at all, and
|
|
266
|
+
// `insertTextAfter(body[0])` would push the declaration below the statement
|
|
267
|
+
// that needs it — legal, since imports hoist, but it reads as though the
|
|
268
|
+
// fixer lost track of the file.
|
|
269
|
+
return targetNode.type === 'ImportDeclaration'
|
|
270
|
+
? fixer.insertTextAfter(targetNode, `\n${declaration}`)
|
|
271
|
+
: insertAboveWithComments(fixer, sourceCode, targetNode, `${declaration}\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildFix(fixer, node, unsafeFn, classification, sourceCode, state) {
|
|
275
|
+
// REPAIR: an orphaned `safePath.join(...)` is already the call we want, and
|
|
276
|
+
// the only thing missing is the import that a discarded report was carrying.
|
|
277
|
+
//
|
|
278
|
+
// This deliberately ignores `state.hasSafePathImport`. That flag is mutated
|
|
279
|
+
// inside `fix()`, and ESLint runs `fix()` for a SUPPRESSED problem before the
|
|
280
|
+
// disable filter throws it away — so on every pass the suppressed report
|
|
281
|
+
// spends the flag first and the repair emits nothing. The file then never
|
|
282
|
+
// recovers, which is precisely the stable broken fixpoint this leg exists to
|
|
283
|
+
// break. The gate that makes ignoring the flag safe is immutable: this
|
|
284
|
+
// classification is only reached when `safePath` is unbound in the SOURCE.
|
|
285
|
+
//
|
|
286
|
+
// Several orphaned calls yield the identical insert at the identical anchor,
|
|
287
|
+
// so ESLint applies one and drops the rest as overlapping — which is the
|
|
288
|
+
// desired outcome, not a hazard.
|
|
289
|
+
if (classification.importOnly) {
|
|
290
|
+
return [importSafePath(fixer, sourceCode, state)];
|
|
291
|
+
}
|
|
292
|
+
|
|
116
293
|
const fixes = [fixer.replaceText(node.callee, `${SAFE_OBJECT}.${unsafeFn}`)];
|
|
117
294
|
|
|
118
295
|
if (!state.hasSafePathImport) {
|
|
119
|
-
|
|
120
|
-
const lastSpec = state.safeImportNode.specifiers.at(-1);
|
|
121
|
-
fixes.push(fixer.insertTextAfter(lastSpec, `, ${SAFE_OBJECT}`));
|
|
122
|
-
} else {
|
|
123
|
-
const targetNode = state.namedImportNode || sourceCode.ast.body[0];
|
|
124
|
-
fixes.push(fixer.insertTextAfter(targetNode, `\nimport { ${SAFE_OBJECT} } from '${state.safeModule}';`));
|
|
125
|
-
}
|
|
296
|
+
fixes.push(importSafePath(fixer, sourceCode, state));
|
|
126
297
|
state.hasSafePathImport = true;
|
|
127
298
|
}
|
|
128
299
|
|
|
129
|
-
if (
|
|
300
|
+
if (
|
|
301
|
+
classification.isNamed &&
|
|
302
|
+
state.namedImportNode &&
|
|
303
|
+
!state.namedImportRemoved &&
|
|
304
|
+
!isReExported(sourceCode, unsafeFn)
|
|
305
|
+
) {
|
|
130
306
|
fixes.push(...removeSpecifier(fixer, sourceCode, state.namedImportNode, state.namedImportSpec));
|
|
307
|
+
state.namedImportRemoved = true;
|
|
131
308
|
}
|
|
132
309
|
|
|
133
310
|
return fixes;
|
|
@@ -148,6 +325,7 @@ module.exports = function createPathFunctionRule(config) {
|
|
|
148
325
|
schema: [EXEMPT_AND_SAFE_MODULE_SCHEMA],
|
|
149
326
|
messages: {
|
|
150
327
|
noUnsafePathFn: message,
|
|
328
|
+
[DEAD_UNSAFE_IMPORT]: DEAD_UNSAFE_IMPORT_MESSAGE,
|
|
151
329
|
[UNANCHORED_EXEMPT_FILE]: UNANCHORED_EXEMPT_MESSAGE,
|
|
152
330
|
},
|
|
153
331
|
},
|
|
@@ -170,12 +348,25 @@ module.exports = function createPathFunctionRule(config) {
|
|
|
170
348
|
safeModule: resolveSafeModule(context, SAFE_PATH_MODULE),
|
|
171
349
|
namedImportSpec: null,
|
|
172
350
|
namedImportNode: null,
|
|
351
|
+
// Both of these guard a SHARED edit against being emitted by more than
|
|
352
|
+
// one report — see `buildFix` for what ESLint does with the overlap.
|
|
353
|
+
namedImportRemoved: false,
|
|
173
354
|
defaultImportName: null,
|
|
174
355
|
// Seeded from SCOPE, not from "did I see an import from SAFE_MODULE?".
|
|
175
356
|
// A file already importing `safePath` from the barrel needs the call
|
|
176
357
|
// rewritten but must NOT gain a second binding of the same name.
|
|
177
358
|
hasSafePathImport: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
|
|
359
|
+
// The SAME question, answered once and never mutated. `hasSafePathImport`
|
|
360
|
+
// flips to true the moment a fix inserts the import, and gating the
|
|
361
|
+
// repair leg on a flag that the first report can flip would arm it for
|
|
362
|
+
// the rest of THIS pass — re-admitting the ambient-global false positive
|
|
363
|
+
// in any file that also has a `path.join()` to fix.
|
|
364
|
+
safePathBoundInSource: isNameAlreadyBound(sourceCode, SAFE_OBJECT),
|
|
178
365
|
safeImportNode: null,
|
|
366
|
+
// EVERY path-module declaration, not just the one carrying `unsafeFn`.
|
|
367
|
+
// A file's dead binding is `import path from 'node:path'`, which
|
|
368
|
+
// `trackPathImport` only ever recorded as a NAME. See `dead-import.cjs`.
|
|
369
|
+
pathImportNodes: [],
|
|
179
370
|
};
|
|
180
371
|
|
|
181
372
|
return {
|
|
@@ -183,8 +374,18 @@ module.exports = function createPathFunctionRule(config) {
|
|
|
183
374
|
reportUnanchoredExemptEntries(context, node);
|
|
184
375
|
},
|
|
185
376
|
|
|
377
|
+
'Program:exit'() {
|
|
378
|
+
reportDeadUnsafeImports(
|
|
379
|
+
context,
|
|
380
|
+
sourceCode,
|
|
381
|
+
state.pathImportNodes,
|
|
382
|
+
state.safePathBoundInSource,
|
|
383
|
+
);
|
|
384
|
+
},
|
|
385
|
+
|
|
186
386
|
ImportDeclaration(node) {
|
|
187
387
|
if (PATH_MODULES.has(node.source.value)) {
|
|
388
|
+
state.pathImportNodes.push(node);
|
|
188
389
|
trackPathImport(node, unsafeFn, state);
|
|
189
390
|
}
|
|
190
391
|
if (node.source.value === state.safeModule) {
|
|
@@ -193,7 +394,7 @@ module.exports = function createPathFunctionRule(config) {
|
|
|
193
394
|
},
|
|
194
395
|
|
|
195
396
|
CallExpression(node) {
|
|
196
|
-
const classification = classifyCall(node, unsafeFn, state);
|
|
397
|
+
const classification = classifyCall(node, unsafeFn, state, sourceCode);
|
|
197
398
|
if (!classification) {
|
|
198
399
|
return;
|
|
199
400
|
}
|
|
@@ -206,7 +407,7 @@ module.exports = function createPathFunctionRule(config) {
|
|
|
206
407
|
// drift from where the fixer actually writes the import.
|
|
207
408
|
data: { safeModule: state.safeModule },
|
|
208
409
|
fix(fixer) {
|
|
209
|
-
return buildFix(fixer, node, unsafeFn, classification
|
|
410
|
+
return buildFix(fixer, node, unsafeFn, classification, sourceCode, state);
|
|
210
411
|
},
|
|
211
412
|
});
|
|
212
413
|
},
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ESLint rule: prefer-startswith-over-regex
|
|
3
3
|
*
|
|
4
|
-
* Catches `/^literal/.test(s)` and `/literal$/.test(s)` patterns
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Catches `/^literal/.test(s)` and `/literal$/.test(s)` patterns whose body
|
|
5
|
+
* flattens to a plain string, and recommends `s.startsWith('literal')` /
|
|
6
|
+
* `s.endsWith('literal')`.
|
|
7
7
|
*
|
|
8
8
|
* Why a local rule?
|
|
9
9
|
* `unicorn/prefer-string-starts-ends-with` already handles the simple case
|
|
@@ -11,11 +11,32 @@
|
|
|
11
11
|
* common `\/` (escaped slash) sequence. SonarCloud's S6557 catches these,
|
|
12
12
|
* but only post-merge. This rule shifts that detection left into ESLint.
|
|
13
13
|
*
|
|
14
|
+
* ## Two shapes this rule deliberately does NOT limit itself to
|
|
15
|
+
*
|
|
16
|
+
* Both were narrowings in the first draft, and an adopter found each of them
|
|
17
|
+
* the same way: SonarCloud raised a MAJOR S6557 on code this rule had reported
|
|
18
|
+
* green.
|
|
19
|
+
*
|
|
20
|
+
* 1. **The regex need not be inline.** `const RE = /^x/; RE.test(s)` is the
|
|
21
|
+
* same violation as `/^x/.test(s)` — see {@link resolveRegex}.
|
|
22
|
+
* 2. **An escaped non-special character is a literal character.** `\*` is an
|
|
23
|
+
* unambiguous `*`; refusing every escape but `\/` skipped it — see
|
|
24
|
+
* {@link literalEquivalent}.
|
|
25
|
+
*
|
|
26
|
+
* Neither could have been caught by scanning an adopter's tree. The rule runs
|
|
27
|
+
* at `error` there, so its finding count is zero BY CONSTRUCTION — lint cannot
|
|
28
|
+
* go green while a violation exists. A 0-vs-0 tie against another
|
|
29
|
+
* implementation is not agreement, it is two rules both failing to fire. For
|
|
30
|
+
* any rule an adopter reports zero findings for, that rule is *unmeasured*.
|
|
31
|
+
*
|
|
14
32
|
* Examples:
|
|
15
33
|
* /^file:\/\//.test(s) → s.startsWith('file://')
|
|
34
|
+
* /^\*glob/.test(s) → s.startsWith('*glob')
|
|
35
|
+
* const R = /^a/; R.test(s) → s.startsWith('a')
|
|
16
36
|
* /^https?:\/\//.test(s) → NOT flagged (contains `?` quantifier)
|
|
17
37
|
* /^[a-z]+/.test(s) → NOT flagged (contains `[` character class)
|
|
18
|
-
* /\.txt$/.test(s) → NOT flagged (
|
|
38
|
+
* /\.txt$/.test(s) → NOT flagged (`.` is a metachar; `\.` would flag)
|
|
39
|
+
* /^\d+/.test(s) → NOT flagged (`\d` is a character class)
|
|
19
40
|
*/
|
|
20
41
|
|
|
21
42
|
'use strict';
|
|
@@ -23,24 +44,126 @@
|
|
|
23
44
|
const METACHARS = new Set(['^', '$', '+', '[', '{', '(', '.', '?', '*', '|']);
|
|
24
45
|
|
|
25
46
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
47
|
+
* Escape sequences whose meaning is NOT "the character that follows the
|
|
48
|
+
* backslash": character classes (`\d`, `\w`, `\s`, `\p`), assertions (`\b`),
|
|
49
|
+
* numeric escapes and backreferences (`\0`–`\9`, `\k`), and the code-point
|
|
50
|
+
* forms (`\x`, `\u`, `\c`). `\n`, `\r`, `\t`, `\v`, `\f` ARE single literal
|
|
51
|
+
* characters, but flattening them would put a raw control character into the
|
|
52
|
+
* suggested `startsWith('…')` string, so they are rejected too.
|
|
53
|
+
*
|
|
54
|
+
* Every other escape — `\/`, `\.`, `\*`, `\+`, `\(`, `\\`, `\-` … — is an
|
|
55
|
+
* identity escape, and the character it protects is exactly what a
|
|
56
|
+
* `startsWith` comparison would look for.
|
|
57
|
+
*/
|
|
58
|
+
const MEANINGFUL_ESCAPE = /[0-9BDPSWbcdfknprstuvwx]/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Flatten a regex body to the plain string it is equivalent to.
|
|
62
|
+
*
|
|
63
|
+
* Returns the literal string if safely convertible, otherwise null. Scans
|
|
64
|
+
* character by character rather than doing a `replaceAll` of the one escape we
|
|
65
|
+
* happen to like: `\/` was accepted and `\*` was not, though both denote a
|
|
66
|
+
* single literal character and neither is a metacharacter once escaped.
|
|
29
67
|
*/
|
|
30
68
|
function literalEquivalent(patternBody) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
let literal = '';
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < patternBody.length; index += 1) {
|
|
72
|
+
const char = patternBody[index];
|
|
73
|
+
|
|
74
|
+
if (char === '\\') {
|
|
75
|
+
const escaped = patternBody[index + 1];
|
|
76
|
+
// A trailing lone backslash is not a valid pattern; refuse to guess.
|
|
77
|
+
if (escaped === undefined || MEANINGFUL_ESCAPE.test(escaped)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
literal += escaped;
|
|
81
|
+
index += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Unescaped metacharacter: flattening it would change what matches.
|
|
86
|
+
if (METACHARS.has(char)) {
|
|
40
87
|
return null;
|
|
41
88
|
}
|
|
89
|
+
literal += char;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return literal;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Find the variable `identifier` resolves to, searching outward from its scope.
|
|
97
|
+
*/
|
|
98
|
+
function findVariable(sourceCode, identifier) {
|
|
99
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
100
|
+
const found = scope.variables.find((variable) => variable.name === identifier.name);
|
|
101
|
+
if (found) {
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The regex `node` denotes: itself when it is a regex literal, or the literal
|
|
110
|
+
* a single-assignment variable was initialised with.
|
|
111
|
+
*
|
|
112
|
+
* The indirection matters because hoisting a regex to a module-level `const` is
|
|
113
|
+
* the normal way to write one — and examining only inline literals meant the
|
|
114
|
+
* rule went quiet on exactly the code most likely to run hot.
|
|
115
|
+
*
|
|
116
|
+
* Conservative on purpose: one definition, one write, and that write is a regex
|
|
117
|
+
* literal. A binding assigned more than once could hold anything by the time
|
|
118
|
+
* `.test()` runs, and nothing here proves which value that is.
|
|
119
|
+
*
|
|
120
|
+
* `indirect` is what lets the caller treat a hoisted regex differently from an
|
|
121
|
+
* inline one — see the `g`/`y` guard, which only a shared object can trip.
|
|
122
|
+
*
|
|
123
|
+
* @returns {{pattern: string, flags: string, indirect: boolean} | null}
|
|
124
|
+
*/
|
|
125
|
+
function resolveRegex(sourceCode, node) {
|
|
126
|
+
if (node.type === 'Literal' && node.regex) {
|
|
127
|
+
return { ...node.regex, indirect: false };
|
|
128
|
+
}
|
|
129
|
+
if (node.type !== 'Identifier') {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const variable = findVariable(sourceCode, node);
|
|
134
|
+
if (variable?.defs.length !== 1) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const [definition] = variable.defs;
|
|
138
|
+
if (definition.type !== 'Variable' || !definition.node.init) {
|
|
139
|
+
return null;
|
|
42
140
|
}
|
|
43
|
-
|
|
141
|
+
if (variable.references.filter((reference) => reference.isWrite()).length !== 1) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { init } = definition.node;
|
|
146
|
+
return init.type === 'Literal' && init.regex ? { ...init.regex, indirect: true } : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render the flattened literal as JS SOURCE, not as a bare character run.
|
|
151
|
+
*
|
|
152
|
+
* The literal is a string of characters; the message drops it into
|
|
153
|
+
* `startsWith(…)`, which a human reads as source. Those are different
|
|
154
|
+
* languages, and interpolating one into the other loses exactly the characters
|
|
155
|
+
* that matter. `/^C:\\Users/` flattens to `C:\Users` — one backslash — and
|
|
156
|
+
* emitting it raw produced the advice `startsWith('C:\Users')`, which JavaScript
|
|
157
|
+
* reads back as `"C:Users"`. Worse in the realistic case: a `/^\\\\/` UNC check
|
|
158
|
+
* became `startsWith('\\')`, i.e. ONE backslash, silently true for any
|
|
159
|
+
* single-backslash path. A literal containing `'` produced advice that is a
|
|
160
|
+
* `SyntaxError` outright.
|
|
161
|
+
*
|
|
162
|
+
* This rule has no fixer, so the message IS the deliverable — there is no
|
|
163
|
+
* autofixer downstream that would have escaped it correctly.
|
|
164
|
+
*/
|
|
165
|
+
function asSourceLiteral(literal) {
|
|
166
|
+
return JSON.stringify(literal);
|
|
44
167
|
}
|
|
45
168
|
|
|
46
169
|
module.exports = {
|
|
@@ -48,21 +171,26 @@ module.exports = {
|
|
|
48
171
|
type: 'problem',
|
|
49
172
|
docs: {
|
|
50
173
|
description:
|
|
51
|
-
String.raw`Prefer String#startsWith / String#endsWith over /^literal/.test() —
|
|
174
|
+
String.raw`Prefer String#startsWith / String#endsWith over /^literal/.test() — including escaped literals such as \/ and \*, and regexes held in a const`,
|
|
52
175
|
recommended: true,
|
|
53
176
|
},
|
|
54
177
|
messages: {
|
|
178
|
+
// `{{pattern}}` carries its FLAGS. Rendering `/^abc/` for a source
|
|
179
|
+
// `/^abc/g` hid the one character that decides whether the advice is
|
|
180
|
+
// right, from the one person positioned to notice.
|
|
55
181
|
preferStartsWith:
|
|
56
|
-
|
|
57
|
-
String.raw`
|
|
182
|
+
'Prefer `<string>.startsWith({{literal}})` over `/{{pattern}}/{{flags}}.test(<string>)`. ' +
|
|
183
|
+
String.raw`An escaped character such as \/ or \* is the literal character itself.`,
|
|
58
184
|
preferEndsWith:
|
|
59
|
-
|
|
60
|
-
String.raw`
|
|
185
|
+
'Prefer `<string>.endsWith({{literal}})` over `/{{pattern}}/{{flags}}.test(<string>)`. ' +
|
|
186
|
+
String.raw`An escaped character such as \/ or \* is the literal character itself.`,
|
|
61
187
|
},
|
|
62
188
|
schema: [],
|
|
63
189
|
},
|
|
64
190
|
|
|
65
191
|
create(context) {
|
|
192
|
+
const sourceCode = context.getSourceCode();
|
|
193
|
+
|
|
66
194
|
return {
|
|
67
195
|
CallExpression(node) {
|
|
68
196
|
if (
|
|
@@ -72,37 +200,55 @@ module.exports = {
|
|
|
72
200
|
) {
|
|
73
201
|
return;
|
|
74
202
|
}
|
|
75
|
-
|
|
76
|
-
|
|
203
|
+
// `startsWith` needs a string receiver where `.test()` would have
|
|
204
|
+
// coerced one. Arity is the only part of that this rule can check
|
|
205
|
+
// without types — a zero-argument `.test()` coerces `undefined` to
|
|
206
|
+
// "undefined" and is nobody's prefix check. A non-string ARGUMENT
|
|
207
|
+
// (`/^\[object/.test(v)`) remains a known limitation: `.test` coerces,
|
|
208
|
+
// `startsWith` throws, and only a type checker can tell them apart.
|
|
209
|
+
if (node.arguments.length !== 1) {
|
|
77
210
|
return;
|
|
78
211
|
}
|
|
79
|
-
|
|
212
|
+
|
|
213
|
+
const regex = resolveRegex(sourceCode, node.callee.object);
|
|
214
|
+
if (!regex) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const { pattern, flags, indirect } = regex;
|
|
80
218
|
if (flags.includes('i') || flags.includes('m')) {
|
|
81
219
|
return;
|
|
82
220
|
}
|
|
221
|
+
// `g` and `y` make `.test()` STATEFUL through `lastIndex`. A regex
|
|
222
|
+
// LITERAL is reconstructed on every evaluation, so its cursor is always
|
|
223
|
+
// 0 and the flags are inert; a hoisted `const` is one object that
|
|
224
|
+
// remembers. `const RE = /^abc/g` answers [true, false, true, false] to
|
|
225
|
+
// four calls on the same string where `startsWith` answers true four
|
|
226
|
+
// times — so resolving through a binding is precisely what makes this
|
|
227
|
+
// advice wrong, and precisely where it must not be given.
|
|
228
|
+
if (indirect && (flags.includes('g') || flags.includes('y'))) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const report = (messageId, literal) => {
|
|
233
|
+
context.report({
|
|
234
|
+
node,
|
|
235
|
+
messageId,
|
|
236
|
+
data: { literal: asSourceLiteral(literal), pattern, flags },
|
|
237
|
+
});
|
|
238
|
+
};
|
|
83
239
|
|
|
84
240
|
if (pattern.startsWith('^')) {
|
|
85
|
-
const
|
|
86
|
-
const literal = literalEquivalent(body);
|
|
241
|
+
const literal = literalEquivalent(pattern.slice(1));
|
|
87
242
|
if (literal !== null && literal !== '') {
|
|
88
|
-
|
|
89
|
-
node,
|
|
90
|
-
messageId: 'preferStartsWith',
|
|
91
|
-
data: { literal, pattern },
|
|
92
|
-
});
|
|
243
|
+
report('preferStartsWith', literal);
|
|
93
244
|
return;
|
|
94
245
|
}
|
|
95
246
|
}
|
|
96
247
|
|
|
97
248
|
if (pattern.endsWith('$') && !pattern.endsWith(String.raw`\$`)) {
|
|
98
|
-
const
|
|
99
|
-
const literal = literalEquivalent(body);
|
|
249
|
+
const literal = literalEquivalent(pattern.slice(0, -1));
|
|
100
250
|
if (literal !== null && literal !== '') {
|
|
101
|
-
|
|
102
|
-
node,
|
|
103
|
-
messageId: 'preferEndsWith',
|
|
104
|
-
data: { literal, pattern },
|
|
105
|
-
});
|
|
251
|
+
report('preferEndsWith', literal);
|
|
106
252
|
}
|
|
107
253
|
}
|
|
108
254
|
},
|
|
@@ -68,6 +68,28 @@ function isNameAlreadyBound(sourceCode, name) {
|
|
|
68
68
|
return scope.variables.some((variable) => variable.name === name);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Insert `text` above `node`, ABOVE its leading comments.
|
|
73
|
+
*
|
|
74
|
+
* `fixer.insertTextBefore(node)` uses the node's own start offset, which is
|
|
75
|
+
* after any comment attached to it — so inserting an import before the first
|
|
76
|
+
* statement dropped it BETWEEN an `eslint-disable-next-line` and the line that
|
|
77
|
+
* directive protects. The directive then applies to the inserted import, and
|
|
78
|
+
* the statement the developer had deliberately suppressed silently becomes
|
|
79
|
+
* fixable. A fixer that can revoke a suppression is a fixer that edits code
|
|
80
|
+
* nobody asked it to touch.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} fixer - ESLint rule fixer.
|
|
83
|
+
* @param {object} sourceCode - ESLint `SourceCode` for the file being fixed.
|
|
84
|
+
* @param {object} node - The node to insert above.
|
|
85
|
+
* @param {string} text - Text to insert, including its own trailing newline.
|
|
86
|
+
*/
|
|
87
|
+
function insertAboveWithComments(fixer, sourceCode, node, text) {
|
|
88
|
+
const comments = sourceCode.getCommentsBefore(node);
|
|
89
|
+
const start = (comments[0] ?? node).range[0];
|
|
90
|
+
return fixer.insertTextBeforeRange([start, start], text);
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
/**
|
|
72
94
|
* The `safeModule` rule option: point the fixer at YOUR re-export seam.
|
|
73
95
|
*
|
|
@@ -135,6 +157,7 @@ module.exports = {
|
|
|
135
157
|
SAFE_MODULE_ONLY_SCHEMA,
|
|
136
158
|
SAFE_PATH_MODULE,
|
|
137
159
|
SAFE_PROCESS_MODULE,
|
|
160
|
+
insertAboveWithComments,
|
|
138
161
|
isNameAlreadyBound,
|
|
139
162
|
resolveSafeModule,
|
|
140
163
|
withSafeModuleOption,
|