@blumintinc/eslint-plugin-blumint 1.18.4 → 1.18.6

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 CHANGED
@@ -218,7 +218,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
218
218
  module.exports = {
219
219
  meta: {
220
220
  name: '@blumintinc/eslint-plugin-blumint',
221
- version: '1.18.4',
221
+ version: '1.18.6',
222
222
  },
223
223
  parseOptions: {
224
224
  ecmaVersion: 2020,
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforcePropsArgumentName = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
6
7
  exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
7
8
  name: 'enforce-props-argument-name',
8
9
  meta: {
@@ -195,15 +196,89 @@ exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
195
196
  }
196
197
  });
197
198
  }
199
+ // Determine whether renaming a constructor parameter property is unsafe to
200
+ // autofix. A parameter property (`private readonly foo: T`) creates BOTH a
201
+ // constructor-local binding and a `this.foo` class field, so a
202
+ // declaration-only rename would leave dangling references: plain `foo`
203
+ // usages inside the constructor (e.g. `super(foo)`) and `this.foo` accesses
204
+ // anywhere in the class. Mirrors #1123 — when a rename cannot be applied
205
+ // everywhere syntactically, emit no fix rather than corrupt the code.
206
+ function parameterPropertyRenameIsUnsafe(classNode, name, declarationId) {
207
+ let unsafe = false;
208
+ const visit = (node) => {
209
+ if (unsafe) {
210
+ return;
211
+ }
212
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
213
+ node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
214
+ node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
215
+ node.property.name === name) {
216
+ unsafe = true;
217
+ return;
218
+ }
219
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier &&
220
+ node.name === name &&
221
+ node !== declarationId) {
222
+ unsafe = true;
223
+ return;
224
+ }
225
+ for (const key of Object.keys(node)) {
226
+ if (key === 'parent') {
227
+ continue;
228
+ }
229
+ const value = node[key];
230
+ if (Array.isArray(value)) {
231
+ for (const child of value) {
232
+ if (ASTHelpers_1.ASTHelpers.isNode(child)) {
233
+ visit(child);
234
+ }
235
+ }
236
+ }
237
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
238
+ visit(value);
239
+ }
240
+ }
241
+ };
242
+ visit(classNode);
243
+ return unsafe;
244
+ }
245
+ // Find the class (declaration or expression) that owns a method definition.
246
+ function getEnclosingClass(node) {
247
+ const classBody = node.parent;
248
+ if (classBody && classBody.type === utils_1.AST_NODE_TYPES.ClassBody) {
249
+ const classNode = classBody.parent;
250
+ if (classNode &&
251
+ (classNode.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
252
+ classNode.type === utils_1.AST_NODE_TYPES.ClassExpression)) {
253
+ return classNode;
254
+ }
255
+ }
256
+ return null;
257
+ }
198
258
  // Check class method parameters (including constructors)
199
259
  function checkClassMethod(node) {
200
260
  const method = node.value;
201
261
  const sourceCode = context.sourceCode;
202
262
  const propsParams = getPropsParams(method.params);
263
+ // When the enclosing class extends a base class, a constructor parameter
264
+ // property (e.g. `private readonly fullProps: SubProps`) cannot be safely
265
+ // renamed to `props`: the base class may already declare a private `props`
266
+ // field/parameter-property, so the rename would create a TS2415 private-
267
+ // field collision, and it would also leave `super(fullProps)` / `this.
268
+ // fullProps` references dangling. The rule is purely syntactic and cannot
269
+ // inspect the base class, so it defers to TypeScript correctness here and
270
+ // does not report on parameter properties in subclasses (this repo prefers
271
+ // false negatives over false positives).
272
+ const enclosingClass = getEnclosingClass(node);
273
+ const classExtendsBase = !!enclosingClass?.superClass;
203
274
  method.params.forEach((param) => {
204
275
  if (isDestructuredParameter(param)) {
205
276
  return;
206
277
  }
278
+ if (classExtendsBase &&
279
+ param.type === utils_1.AST_NODE_TYPES.TSParameterProperty) {
280
+ return;
281
+ }
207
282
  const id = getIdFromParam(param);
208
283
  if (id && id.typeAnnotation && id.typeAnnotation.typeAnnotation) {
209
284
  const typeName = getTypeName(id.typeAnnotation.typeAnnotation);
@@ -221,6 +296,15 @@ exports.enforcePropsArgumentName = (0, createRule_1.createRule)({
221
296
  suggestedName,
222
297
  },
223
298
  fix: (fixer) => {
299
+ // A parameter-property rename touches both the parameter and
300
+ // the `this.<name>` field; refuse to autofix when the name is
301
+ // referenced elsewhere, since a declaration-only rename would
302
+ // leave dangling references.
303
+ if (param.type === utils_1.AST_NODE_TYPES.TSParameterProperty &&
304
+ enclosingClass &&
305
+ parameterPropertyRenameIsUnsafe(enclosingClass, id.name, id)) {
306
+ return null;
307
+ }
224
308
  const token = sourceCode.getFirstToken(id);
225
309
  if (!token)
226
310
  return null;
@@ -67,6 +67,52 @@ const ALLOWED_SUFFIXES = [
67
67
  ];
68
68
  // Common compound nouns that should not be flagged as Hungarian notation
69
69
  const ALLOWED_COMPOUND_NOUNS = ['PhoneNumber', 'EmailAddress', 'PostalCode'];
70
+ // Domain-entity head nouns that legitimately precede a "Number" suffix. In
71
+ // <entity>Number the trailing "Number" is the HEAD NOUN of the domain concept
72
+ // (the number OF an issue/line/round/version — GitHub's REST field is literally
73
+ // `issue_number`), not a type marker bolted onto the name. Removing it yields a
74
+ // wrong name (`issue` denotes the whole issue object, not its number), so these
75
+ // are domain compounds, not Hungarian notation — the same reasoning that
76
+ // motivated PhoneNumber/EmailAddress/PostalCode (#640), generalized to the whole
77
+ // <entity>Number category (#1277). Words that are themselves quantities (count,
78
+ // age, index, size, amount, ...) are intentionally ABSENT: for them "Number" is
79
+ // a redundant type tag, so <quantity>Number stays flagged as Hungarian.
80
+ const DOMAIN_NUMBER_HEAD_NOUNS = new Set([
81
+ 'phone',
82
+ 'issue',
83
+ 'line',
84
+ 'round',
85
+ 'version',
86
+ 'account',
87
+ 'match',
88
+ 'order',
89
+ 'invoice',
90
+ 'ticket',
91
+ 'serial',
92
+ 'model',
93
+ 'page',
94
+ 'reference',
95
+ 'confirmation',
96
+ 'tracking',
97
+ 'license',
98
+ 'part',
99
+ 'revision',
100
+ 'build',
101
+ 'sequence',
102
+ 'port',
103
+ 'card',
104
+ 'contract',
105
+ 'document',
106
+ 'receipt',
107
+ 'registration',
108
+ 'flight',
109
+ 'room',
110
+ 'seat',
111
+ 'block',
112
+ 'route',
113
+ 'channel',
114
+ 'badge',
115
+ ]);
70
116
  // Common built-in JavaScript prototype methods
71
117
  const BUILT_IN_METHODS = new Set([
72
118
  // String methods
@@ -234,6 +280,24 @@ function isSemanticTypeConcept(typeName) {
234
280
  // concept (e.g. Extract+Number) rather than bare type tags glued together.
235
281
  return segments.some((segment) => !FULL_TYPE_WORDS.has(segment.toLowerCase()));
236
282
  }
283
+ // Is `name` a domain compound of the form <entity>Number, where the word directly
284
+ // before the trailing "Number" is a known domain-entity noun (issueNumber,
285
+ // lineNumber, roundNumber, versionNumber)? Only the LAST head segment is
286
+ // consulted, so prefixed variants generalize (githubIssueNumber, currentLineNumber
287
+ // pass) while numeric-head compounds still read as Hungarian (maxCountNumber ->
288
+ // head segment "Count", not a domain entity -> still flagged).
289
+ function isDomainNumberCompound(name) {
290
+ if (!name.endsWith('Number')) {
291
+ return false;
292
+ }
293
+ const head = name.slice(0, -'Number'.length);
294
+ if (head.length === 0) {
295
+ return false;
296
+ }
297
+ const segments = splitCamelSegments(head);
298
+ const lastSegment = segments[segments.length - 1];
299
+ return (!!lastSegment && DOMAIN_NUMBER_HEAD_NOUNS.has(lastSegment.toLowerCase()));
300
+ }
237
301
  exports.noHungarian = (0, createRule_1.createRule)({
238
302
  name: 'no-hungarian',
239
303
  meta: {
@@ -374,6 +438,21 @@ exports.noHungarian = (0, createRule_1.createRule)({
374
438
  normalizedVarName.length > normalizedMarker.length &&
375
439
  (/[A-Z0-9]/.test(variableName[variableName.length - normalizedMarker.length - 1]) ||
376
440
  /[A-Z]/.test(variableName[variableName.length - normalizedMarker.length]))) {
441
+ // A trailing "...Number" whose head noun is a domain entity
442
+ // (issueNumber, lineNumber, roundNumber, versionNumber) is a domain
443
+ // compound, not a Hungarian type tag: the suffix names WHAT the value
444
+ // is (the number OF an issue — GitHub's REST field is `issue_number`),
445
+ // and stripping it destroys the concept (`issue` = the whole object).
446
+ // Generalizes #640's PhoneNumber/EmailAddress/PostalCode carve-out to
447
+ // the whole <entity>Number category (#1277). Scoped to the full-word
448
+ // `Number` marker only: abbreviation tags (str/num/obj/arr/bool) are
449
+ // handled above and still fire, and numeric-quantity heads
450
+ // (countNumber, ageNumber, indexNumber) keep firing because such heads
451
+ // are deliberately absent from DOMAIN_NUMBER_HEAD_NOUNS.
452
+ if (normalizedMarker === 'number' &&
453
+ isDomainNumberCompound(variableName)) {
454
+ return false;
455
+ }
377
456
  return true;
378
457
  }
379
458
  // Full type-word markers (non-abbreviations: String, Number, Function,
@@ -1 +1,2 @@
1
- export declare const noUnnecessaryVerbSuffix: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"unnecessaryVerbSuffix", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const noUnnecessaryVerbSuffix: TSESLint.RuleModule<"unnecessaryVerbSuffix", [], TSESLint.RuleListener>;
@@ -166,6 +166,46 @@ function isExported(node) {
166
166
  }
167
167
  return false;
168
168
  }
169
+ /**
170
+ * Walks a scope chain upward from `scope` (inclusive) and reports whether
171
+ * `targetName` is bound anywhere between `scope` and `stopScope` (inclusive).
172
+ * Mirrors how the engine resolves an identifier at a use site: the first scope
173
+ * on the chain that declares the name wins. Used to detect whether a rewritten
174
+ * reference would be captured by a binding sitting between it and the
175
+ * declaration it currently resolves to.
176
+ */
177
+ function isNameBoundInChain(scope, stopScope, targetName) {
178
+ let current = scope;
179
+ while (current) {
180
+ if (current.set.has(targetName)) {
181
+ return true;
182
+ }
183
+ if (current === stopScope) {
184
+ break;
185
+ }
186
+ current = current.upper;
187
+ }
188
+ return false;
189
+ }
190
+ /**
191
+ * Walks a scope subtree rooted at `root` and reports whether `targetName` is
192
+ * declared anywhere within it — the renamed function's own parameters and body
193
+ * bindings. A `suggestion` binding here would shadow the function's new name
194
+ * (the self-shadowing trap in #1278).
195
+ */
196
+ function isNameBoundInSubtree(root, targetName) {
197
+ const stack = [root];
198
+ while (stack.length > 0) {
199
+ const scope = stack.pop();
200
+ if (scope.set.has(targetName)) {
201
+ return true;
202
+ }
203
+ for (const child of scope.childScopes) {
204
+ stack.push(child);
205
+ }
206
+ }
207
+ return false;
208
+ }
169
209
  exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
170
210
  name: 'no-unnecessary-verb-suffix',
171
211
  meta: {
@@ -182,6 +222,47 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
182
222
  },
183
223
  defaultOptions: [],
184
224
  create(context) {
225
+ /**
226
+ * Returns true when renaming the symbol to `suggestion` would collide with
227
+ * an existing binding in any scope the rename touches, making the autofix
228
+ * semantics-changing (and thus unsafe). The strip-suffix fix rewrites the
229
+ * declaration plus every in-file reference to `suggestion`; if `suggestion`
230
+ * already resolves to a different binding the rewrite would:
231
+ * - redeclare a name already bound in the declaration scope,
232
+ * - capture a call site onto an intervening binding (e.g. turning
233
+ * `const line = lineAt(...)` into the TDZ self-reference
234
+ * `const line = line(...)`, #1278), or
235
+ * - shadow the function's own new name from inside its body.
236
+ * In every such case the fix is suppressed (report-only) so the developer
237
+ * picks a non-colliding name — the safety standard core rename fixers hold.
238
+ */
239
+ function renameWouldCollide(functionNode, variable, suggestion) {
240
+ const scopeManager = context.sourceCode.scopeManager;
241
+ const functionScope = scopeManager?.acquire(functionNode) ?? null;
242
+ const declarationScope = variable?.scope ?? functionScope?.upper ?? null;
243
+ // (1) Declaration site: a `suggestion` already bound in the scope that
244
+ // holds the declaration would make the rename a redeclaration/shadow.
245
+ if (declarationScope?.set.has(suggestion)) {
246
+ return true;
247
+ }
248
+ // (2) Reference sites: a binding sitting between a reference and the
249
+ // declaration scope would swallow the rewritten identifier — the
250
+ // reference would resolve to that binding instead of the function.
251
+ if (variable && declarationScope) {
252
+ for (const ref of variable.references) {
253
+ const referenceScope = ref.from ?? declarationScope;
254
+ if (isNameBoundInChain(referenceScope, declarationScope, suggestion)) {
255
+ return true;
256
+ }
257
+ }
258
+ }
259
+ // (3) The function's own parameters/body: a `suggestion` binding there
260
+ // would shadow the function's new name.
261
+ if (functionScope && isNameBoundInSubtree(functionScope, suggestion)) {
262
+ return true;
263
+ }
264
+ return false;
265
+ }
185
266
  function checkFunctionName(node, name,
186
267
  /**
187
268
  * The AST node that holds the identifier being renamed.
@@ -251,6 +332,24 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
251
332
  if (exported || !scopeNode || !declarationIdNode) {
252
333
  return null;
253
334
  }
335
+ // Note: context.getDeclaredVariables is the API available in the
336
+ // pinned @typescript-eslint version (the SourceCode-based
337
+ // replacement is not yet in these type definitions).
338
+ // getDeclaredVariables returns ALL variables the node declares
339
+ // (e.g. for a FunctionDeclaration it includes the function name
340
+ // variable AND its parameter variables). Pick the one whose name
341
+ // matches the symbol being renamed so we only follow references
342
+ // to the name, not parameters.
343
+ const declaredVars = context.getDeclaredVariables(scopeNode);
344
+ const targetVariable = declaredVars.find((variable) => variable.name === name) ??
345
+ null;
346
+ // Suppress the fix when the suggested name already binds
347
+ // something in a scope the rename would touch — a rename fixer
348
+ // must never change program semantics or break compilation
349
+ // (#1278).
350
+ if (renameWouldCollide(node, targetVariable, suggestion)) {
351
+ return null;
352
+ }
254
353
  // Scope-tracked symbols (FunctionDeclaration, VariableDeclarator
255
354
  // arrows/functions, named FunctionExpression): rename the
256
355
  // declaration identifier and every in-file reference together so
@@ -258,19 +357,8 @@ exports.noUnnecessaryVerbSuffix = (0, createRule_1.createRule)({
258
357
  const fixes = [
259
358
  fixer.replaceText(declarationIdNode, suggestion),
260
359
  ];
261
- // Note: context.getDeclaredVariables is the API available in the
262
- // pinned @typescript-eslint version (the SourceCode-based
263
- // replacement is not yet in these type definitions).
264
- const declaredVars = context.getDeclaredVariables(scopeNode);
265
- // getDeclaredVariables returns ALL variables the node declares
266
- // (e.g. for a FunctionDeclaration it includes the function name
267
- // variable AND its parameter variables). Filter to the one whose
268
- // name matches the symbol being renamed so we only follow
269
- // references to the name, not parameters.
270
- for (const variable of declaredVars) {
271
- if (variable.name !== name)
272
- continue;
273
- for (const ref of variable.references) {
360
+ if (targetVariable) {
361
+ for (const ref of targetVariable.references) {
274
362
  // Skip the declaration identifier itself — already handled.
275
363
  if (ref.identifier === declarationIdNode)
276
364
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.18.4",
3
+ "version": "1.18.6",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.18.6",
4
+ "date": "2026-07-09T08:45:37.007Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-hungarian",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1277
11
+ ],
12
+ "summary": "exempt domain-entity <noun>Number compounds (closes #1277)"
13
+ },
14
+ {
15
+ "name": "no-unnecessary-verb-suffix",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1278
19
+ ],
20
+ "summary": "bail autofix on rename collision (closes #1278)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.18.5",
26
+ "date": "2026-07-09T03:26:45.486Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-props-argument-name",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1276
33
+ ],
34
+ "summary": "exempt subclass parameter properties to avoid TS2415/TS2304 (closes #1276)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.18.4",
4
40
  "date": "2026-07-08T07:14:09.487Z",