@blumintinc/eslint-plugin-blumint 1.20.1 → 1.20.3
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/lib/index.js +1 -1
- package/lib/rules/no-unnecessary-verb-suffix.js +247 -3
- package/package.json +1 -1
- package/release-manifest.json +28 -0
package/lib/index.js
CHANGED
|
@@ -166,6 +166,28 @@ function isExported(node) {
|
|
|
166
166
|
}
|
|
167
167
|
return false;
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Renames `identifier` to `newName` while leaving every other token inside the
|
|
171
|
+
* identifier's range intact.
|
|
172
|
+
*
|
|
173
|
+
* A TSESTree `Identifier` node's range spans the tokens that trail its name:
|
|
174
|
+
* a type annotation (`validateBy: Validator`), a definite-assignment assertion
|
|
175
|
+
* (`validateBy!: Validator`) and an optional marker (`cbBy?: Fn`). Replacing the
|
|
176
|
+
* whole node therefore deletes them, and dropping a contextual type turns
|
|
177
|
+
* inferred parameters into implicit `any` so the file no longer compiles — a
|
|
178
|
+
* silent corruption, since the rule reports nothing afterwards (#1351). The name
|
|
179
|
+
* is always the identifier's first token, so replacing that token's range alone
|
|
180
|
+
* renames the symbol and nothing else.
|
|
181
|
+
*/
|
|
182
|
+
function renameIdentifier(fixer, sourceCode, identifier, newName) {
|
|
183
|
+
const nameToken = sourceCode.getFirstToken(identifier);
|
|
184
|
+
// The token store yields the name for every real identifier; the arithmetic
|
|
185
|
+
// end keeps the range narrowed even if a token is somehow unavailable.
|
|
186
|
+
const nameEnd = nameToken
|
|
187
|
+
? nameToken.range[1]
|
|
188
|
+
: identifier.range[0] + identifier.name.length;
|
|
189
|
+
return fixer.replaceTextRange([identifier.range[0], nameEnd], newName);
|
|
190
|
+
}
|
|
169
191
|
/**
|
|
170
192
|
* Walks a scope chain upward from `scope` (inclusive) and reports whether
|
|
171
193
|
* `targetName` is bound anywhere between `scope` and `stopScope` (inclusive).
|
|
@@ -206,6 +228,183 @@ function isNameBoundInSubtree(root, targetName) {
|
|
|
206
228
|
}
|
|
207
229
|
return false;
|
|
208
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Annotations that impose no excess-property check. A value annotated `any` or
|
|
233
|
+
* `unknown` may carry members its annotation never declares, so the annotation
|
|
234
|
+
* proves nothing about where a member name came from (#1350).
|
|
235
|
+
*/
|
|
236
|
+
const UNCHECKED_ANNOTATION_TYPES = new Set([
|
|
237
|
+
utils_1.AST_NODE_TYPES.TSAnyKeyword,
|
|
238
|
+
utils_1.AST_NODE_TYPES.TSUnknownKeyword,
|
|
239
|
+
]);
|
|
240
|
+
/**
|
|
241
|
+
* The declared type of an annotation-bearing node (`const x: T`, `field: T`),
|
|
242
|
+
* or null when the node carries no annotation.
|
|
243
|
+
*/
|
|
244
|
+
function declaredTypeNode(node) {
|
|
245
|
+
const { typeAnnotation } = node;
|
|
246
|
+
return typeAnnotation?.typeAnnotation ?? null;
|
|
247
|
+
}
|
|
248
|
+
function checksExcessProperties(typeNode) {
|
|
249
|
+
return typeNode !== null && !UNCHECKED_ANNOTATION_TYPES.has(typeNode.type);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Reports whether `node` sits inside a value whose shape TypeScript checks
|
|
253
|
+
* against a declared type — a type-annotated variable or class field, or a
|
|
254
|
+
* `satisfies` clause. Excess-property checking makes such a literal unable to
|
|
255
|
+
* carry a member the target type does not declare, so a member name there is
|
|
256
|
+
* dictated by that type rather than chosen by the author, and renaming it would
|
|
257
|
+
* break conformance (#1350). No member resolution is needed: the signal alone
|
|
258
|
+
* is proof, because code carrying an undeclared member does not compile.
|
|
259
|
+
*
|
|
260
|
+
* The walk climbs object/array containers so an outer signal covers nested
|
|
261
|
+
* members, and stops at anything else — notably `as` assertions, which do not
|
|
262
|
+
* reject undeclared members the same way.
|
|
263
|
+
*/
|
|
264
|
+
function hasConformanceSignal(node) {
|
|
265
|
+
let current = node;
|
|
266
|
+
for (;;) {
|
|
267
|
+
const parent = current.parent;
|
|
268
|
+
if (!parent) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
switch (parent.type) {
|
|
272
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
273
|
+
return (parent.expression === current &&
|
|
274
|
+
checksExcessProperties(parent.typeAnnotation));
|
|
275
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
276
|
+
return (parent.init === current &&
|
|
277
|
+
checksExcessProperties(declaredTypeNode(parent.id)));
|
|
278
|
+
case utils_1.AST_NODE_TYPES.PropertyDefinition:
|
|
279
|
+
return (parent.value === current &&
|
|
280
|
+
checksExcessProperties(declaredTypeNode(parent)));
|
|
281
|
+
case utils_1.AST_NODE_TYPES.Property:
|
|
282
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
283
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
284
|
+
current = parent;
|
|
285
|
+
break;
|
|
286
|
+
default:
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function buildDeclarationIndex(sourceCode) {
|
|
292
|
+
const index = {
|
|
293
|
+
interfaces: new Map(),
|
|
294
|
+
typeAliases: new Map(),
|
|
295
|
+
classes: new Map(),
|
|
296
|
+
};
|
|
297
|
+
// Declarations are collected from the whole file rather than from
|
|
298
|
+
// `Program.body` alone so contracts declared inside modules, blocks or
|
|
299
|
+
// functions resolve as well as top-level ones.
|
|
300
|
+
const stack = [sourceCode.ast];
|
|
301
|
+
while (stack.length > 0) {
|
|
302
|
+
const current = stack.pop();
|
|
303
|
+
switch (current.type) {
|
|
304
|
+
case utils_1.AST_NODE_TYPES.TSInterfaceDeclaration: {
|
|
305
|
+
const merged = index.interfaces.get(current.id.name) ?? [];
|
|
306
|
+
merged.push(current);
|
|
307
|
+
index.interfaces.set(current.id.name, merged);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration:
|
|
311
|
+
index.typeAliases.set(current.id.name, current);
|
|
312
|
+
break;
|
|
313
|
+
case utils_1.AST_NODE_TYPES.ClassDeclaration:
|
|
314
|
+
case utils_1.AST_NODE_TYPES.ClassExpression:
|
|
315
|
+
if (current.id) {
|
|
316
|
+
index.classes.set(current.id.name, current);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
default:
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
for (const key of sourceCode.visitorKeys[current.type] ?? []) {
|
|
323
|
+
const value = current[key];
|
|
324
|
+
const children = Array.isArray(value) ? value : [value];
|
|
325
|
+
for (const child of children) {
|
|
326
|
+
if (child && typeof child === 'object' && 'type' in child) {
|
|
327
|
+
stack.push(child);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return index;
|
|
333
|
+
}
|
|
334
|
+
function heritageTypeName(expression) {
|
|
335
|
+
return expression.type === utils_1.AST_NODE_TYPES.Identifier ? expression.name : null;
|
|
336
|
+
}
|
|
337
|
+
function membersDeclareName(members, memberName) {
|
|
338
|
+
return members.some((member) => (member.type === utils_1.AST_NODE_TYPES.TSMethodSignature ||
|
|
339
|
+
member.type === utils_1.AST_NODE_TYPES.TSPropertySignature) &&
|
|
340
|
+
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
341
|
+
member.key.name === memberName);
|
|
342
|
+
}
|
|
343
|
+
function classBodyDeclaresName(members, memberName) {
|
|
344
|
+
return members.some((member) => (member.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
|
|
345
|
+
member.type === utils_1.AST_NODE_TYPES.PropertyDefinition) &&
|
|
346
|
+
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
347
|
+
member.key.name === memberName);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Reports whether the contract named `typeName` accounts for `memberName`,
|
|
351
|
+
* either by declaring it or by being unreadable from this file. A class may add
|
|
352
|
+
* members its contract never declares, so presence of a heritage clause alone
|
|
353
|
+
* cannot exempt a name — but an imported or otherwise unresolvable contract
|
|
354
|
+
* hides its members from a purely syntactic rule, and this plugin prefers a
|
|
355
|
+
* false negative over a false positive (#1350).
|
|
356
|
+
*/
|
|
357
|
+
function contractCoversName(typeName, memberName, index, visited) {
|
|
358
|
+
// A name already inspected on this path adds nothing and would loop on a
|
|
359
|
+
// circular heritage chain.
|
|
360
|
+
if (visited.has(typeName)) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
visited.add(typeName);
|
|
364
|
+
const interfaces = index.interfaces.get(typeName);
|
|
365
|
+
if (interfaces) {
|
|
366
|
+
return interfaces.some((declaration) => membersDeclareName(declaration.body.body, memberName) ||
|
|
367
|
+
heritageCoversName(declaration.extends ?? [], memberName, index, visited));
|
|
368
|
+
}
|
|
369
|
+
const alias = index.typeAliases.get(typeName);
|
|
370
|
+
if (alias) {
|
|
371
|
+
// An alias to anything but a type literal (an intersection, a mapped type,
|
|
372
|
+
// a reference to an imported type) hides its member list from a syntactic
|
|
373
|
+
// reader, so it is treated as unreadable.
|
|
374
|
+
return (alias.typeAnnotation.type !== utils_1.AST_NODE_TYPES.TSTypeLiteral ||
|
|
375
|
+
membersDeclareName(alias.typeAnnotation.members, memberName));
|
|
376
|
+
}
|
|
377
|
+
const classDeclaration = index.classes.get(typeName);
|
|
378
|
+
if (classDeclaration) {
|
|
379
|
+
return (classBodyDeclaresName(classDeclaration.body.body, memberName) ||
|
|
380
|
+
classContractCoversName(classDeclaration, memberName, index, visited));
|
|
381
|
+
}
|
|
382
|
+
// Nothing under this name in the file: the contract lives in another module
|
|
383
|
+
// and its members are unreadable here.
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
function heritageCoversName(heritage, memberName, index, visited) {
|
|
387
|
+
return heritage.some((clause) => {
|
|
388
|
+
const typeName = heritageTypeName(clause.expression);
|
|
389
|
+
// A namespaced or computed heritage expression cannot be followed
|
|
390
|
+
// syntactically, so it counts as an unreadable contract.
|
|
391
|
+
return (typeName === null ||
|
|
392
|
+
contractCoversName(typeName, memberName, index, visited));
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function classContractCoversName(classNode, memberName, index, visited) {
|
|
396
|
+
if (heritageCoversName(classNode.implements ?? [], memberName, index, visited)) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
const { superClass } = classNode;
|
|
400
|
+
if (!superClass) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
const superName = heritageTypeName(superClass);
|
|
404
|
+
// A computed superclass (a mixin call) is unreadable, like an imported one.
|
|
405
|
+
return (superName === null ||
|
|
406
|
+
contractCoversName(superName, memberName, index, visited));
|
|
407
|
+
}
|
|
209
408
|
exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
|
|
210
409
|
name: 'no-unnecessary-verb-suffix',
|
|
211
410
|
meta: {
|
|
@@ -222,6 +421,37 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
|
|
|
222
421
|
},
|
|
223
422
|
defaultOptions: [],
|
|
224
423
|
create(context) {
|
|
424
|
+
// Built on first heritage question only, since most files never ask one.
|
|
425
|
+
let declarationIndex = null;
|
|
426
|
+
function getDeclarationIndex() {
|
|
427
|
+
if (declarationIndex === null) {
|
|
428
|
+
declarationIndex = buildDeclarationIndex(context.sourceCode);
|
|
429
|
+
}
|
|
430
|
+
return declarationIndex;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Reports whether a class member's name comes from a contract the class
|
|
434
|
+
* declares conformance to, rather than from its author (#1350).
|
|
435
|
+
*/
|
|
436
|
+
function isDictatedByHeritage(node, memberName) {
|
|
437
|
+
const classBody = node.parent;
|
|
438
|
+
if (!classBody || classBody.type !== utils_1.AST_NODE_TYPES.ClassBody) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
const classNode = classBody.parent;
|
|
442
|
+
if (!classNode ||
|
|
443
|
+
(classNode.type !== utils_1.AST_NODE_TYPES.ClassDeclaration &&
|
|
444
|
+
classNode.type !== utils_1.AST_NODE_TYPES.ClassExpression)) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
// Indexing the file's declarations is only worth its cost once a class
|
|
448
|
+
// declares conformance to something.
|
|
449
|
+
if ((classNode.implements ?? []).length === 0 &&
|
|
450
|
+
classNode.superClass === null) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
return classContractCoversName(classNode, memberName, getDeclarationIndex(), new Set());
|
|
454
|
+
}
|
|
225
455
|
/**
|
|
226
456
|
* Returns true when renaming the symbol to `suggestion` would collide with
|
|
227
457
|
* an existing binding in any scope the rename touches, making the autofix
|
|
@@ -353,16 +583,19 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
|
|
|
353
583
|
// Scope-tracked symbols (FunctionDeclaration, VariableDeclarator
|
|
354
584
|
// arrows/functions, named FunctionExpression): rename the
|
|
355
585
|
// declaration identifier and every in-file reference together so
|
|
356
|
-
// no call site is left pointing at the old name.
|
|
586
|
+
// no call site is left pointing at the old name. Every rewrite
|
|
587
|
+
// goes through renameIdentifier, because an identifier's range
|
|
588
|
+
// can carry trailing tokens that must survive a rename (#1351).
|
|
589
|
+
const { sourceCode } = context;
|
|
357
590
|
const fixes = [
|
|
358
|
-
fixer
|
|
591
|
+
renameIdentifier(fixer, sourceCode, declarationIdNode, suggestion),
|
|
359
592
|
];
|
|
360
593
|
if (targetVariable) {
|
|
361
594
|
for (const ref of targetVariable.references) {
|
|
362
595
|
// Skip the declaration identifier itself — already handled.
|
|
363
596
|
if (ref.identifier === declarationIdNode)
|
|
364
597
|
continue;
|
|
365
|
-
fixes.push(fixer
|
|
598
|
+
fixes.push(renameIdentifier(fixer, sourceCode, ref.identifier, suggestion));
|
|
366
599
|
}
|
|
367
600
|
}
|
|
368
601
|
return fixes;
|
|
@@ -387,6 +620,11 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
|
|
|
387
620
|
},
|
|
388
621
|
MethodDefinition(node) {
|
|
389
622
|
if (node.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
623
|
+
// A member implementing a contract the class declares conformance to
|
|
624
|
+
// is named by that contract, so renaming it would break conformance.
|
|
625
|
+
if (isDictatedByHeritage(node, node.key.name)) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
390
628
|
// Class methods are called via member expressions (`this.method()`,
|
|
391
629
|
// `instance.method()`) that the scope manager does not track as
|
|
392
630
|
// references. A syntactic single-file fixer therefore cannot find and
|
|
@@ -408,6 +646,12 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
|
|
|
408
646
|
if (node.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
409
647
|
(node.value.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
410
648
|
node.value.type === utils_1.AST_NODE_TYPES.FunctionExpression)) {
|
|
649
|
+
// A literal checked against a declared type can only hold members
|
|
650
|
+
// that type declares, so its member names are not the author's to
|
|
651
|
+
// rename.
|
|
652
|
+
if (hasConformanceSignal(node)) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
411
655
|
// Object-literal method properties are accessed via member expressions
|
|
412
656
|
// (`obj.method()`) the scope manager does not track. As with class
|
|
413
657
|
// methods, the fix is suppressed to avoid orphaning call sites.
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.3",
|
|
4
|
+
"date": "2026-07-25T02:54:46.197Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-unnecessary-verb-suffix",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1351
|
|
11
|
+
],
|
|
12
|
+
"summary": "preserve type annotations when the rename autofix renames an identifier (closes #1351)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.2",
|
|
18
|
+
"date": "2026-07-25T02:42:18.863Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "no-unnecessary-verb-suffix",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1350
|
|
25
|
+
],
|
|
26
|
+
"summary": "exempt member names dictated by a declared contract (closes #1350)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.1",
|
|
4
32
|
"date": "2026-07-24T22:02:37.444Z",
|