@blumintinc/eslint-plugin-blumint 1.19.8 → 1.19.10

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
@@ -222,7 +222,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
222
222
  module.exports = {
223
223
  meta: {
224
224
  name: '@blumintinc/eslint-plugin-blumint',
225
- version: '1.19.8',
225
+ version: '1.19.10',
226
226
  },
227
227
  parseOptions: {
228
228
  ecmaVersion: 2020,
@@ -1,3 +1,4 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type MessageIds = 'upperSnakeCase' | 'asConst';
2
- declare const _default: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<MessageIds, [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
3
+ declare const _default: TSESLint.RuleModule<MessageIds, [], TSESLint.RuleListener>;
3
4
  export default _default;
@@ -3,6 +3,86 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const utils_1 = require("@typescript-eslint/utils");
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const isUpperSnakeCase = (str) => /^[A-Z][A-Z0-9_]*$/.test(str);
6
+ // Jest mock handles produced by an `as` cast to a `jest.Mock*` type are
7
+ // stateful test doubles that are reassigned/mutated through
8
+ // `.mockImplementation()`, `.mockReturnValue()`, etc. They are not immutable
9
+ // module configuration, and the `mockedX` camelCase spelling is the established
10
+ // idiom, so they are exempt from the UPPER_SNAKE_CASE rename requirement.
11
+ const JEST_MOCK_TYPE_NAMES = new Set([
12
+ 'Mock',
13
+ 'MockedFunction',
14
+ 'Mocked',
15
+ 'MockedClass',
16
+ ]);
17
+ // Match `expr as jest.Mock<...>` / `jest.MockedFunction<...>` /
18
+ // `jest.Mocked<...>` / `jest.MockedClass<...>`. The match is kept deliberately
19
+ // narrow — a qualified `jest.<MockType>` type reference — so unrelated `as`
20
+ // casts keep triggering the rename check.
21
+ const isJestMockCast = (node) => {
22
+ if (node.type !== utils_1.AST_NODE_TYPES.TSAsExpression) {
23
+ return false;
24
+ }
25
+ const typeAnnotation = node.typeAnnotation;
26
+ if (typeAnnotation.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
27
+ return false;
28
+ }
29
+ const { typeName } = typeAnnotation;
30
+ return (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName &&
31
+ typeName.left.type === utils_1.AST_NODE_TYPES.Identifier &&
32
+ typeName.left.name === 'jest' &&
33
+ typeName.right.type === utils_1.AST_NODE_TYPES.Identifier &&
34
+ JEST_MOCK_TYPE_NAMES.has(typeName.right.name));
35
+ };
36
+ /**
37
+ * Walks the scope chain upward from `scope` (inclusive) and reports whether
38
+ * `targetName` is bound anywhere between `scope` and `stopScope` (inclusive).
39
+ * Mirrors how the engine resolves an identifier at a use site: the first scope
40
+ * on the chain that declares the name wins. Used to detect whether a rewritten
41
+ * reference would be captured by a binding sitting between it and the
42
+ * declaration it currently resolves to.
43
+ */
44
+ const isNameBoundInChain = (scope, stopScope, targetName) => {
45
+ let current = scope;
46
+ while (current) {
47
+ if (current.set.has(targetName)) {
48
+ return true;
49
+ }
50
+ if (current === stopScope) {
51
+ break;
52
+ }
53
+ current = current.upper;
54
+ }
55
+ return false;
56
+ };
57
+ /**
58
+ * Returns true when renaming `variable` to `newName` would collide with an
59
+ * existing binding in any scope the rename touches, making the autofix
60
+ * semantics-changing (and thus unsafe). The rename fixer rewrites the
61
+ * declaration plus every in-file reference to `newName`; if `newName` already
62
+ * resolves to a different binding the rewrite would either redeclare a name
63
+ * already bound in the declaration scope or capture a reference onto an
64
+ * intervening binding. In every such case the fix is suppressed (report-only).
65
+ */
66
+ const renameWouldCollide = (variable, newName) => {
67
+ const declarationScope = variable.scope;
68
+ // (1) Declaration site: `newName` already bound in the scope that holds the
69
+ // declaration would make the rename a redeclaration/shadow. The declared
70
+ // variable itself carries the old name, so any entry for `newName` is a
71
+ // distinct, colliding binding.
72
+ if (declarationScope.set.has(newName)) {
73
+ return true;
74
+ }
75
+ // (2) Reference sites: a binding of `newName` sitting between a reference and
76
+ // the declaration scope would swallow the rewritten identifier — the
77
+ // reference would resolve to that binding instead of the constant.
78
+ for (const ref of variable.references) {
79
+ const referenceScope = ref.from ?? declarationScope;
80
+ if (isNameBoundInChain(referenceScope, declarationScope, newName)) {
81
+ return true;
82
+ }
83
+ }
84
+ return false;
85
+ };
6
86
  // Next.js recognizes these export names by their literal identifier, so
7
87
  // renaming them to UPPER_SNAKE_CASE silently breaks the framework contract
8
88
  // (e.g. `export const config` controls the API-route body parser / runtime).
@@ -207,12 +287,16 @@ exports.default = (0, createRule_1.createRule)({
207
287
  if (isExported && NEXTJS_RESERVED_EXPORTS.has(name)) {
208
288
  return;
209
289
  }
210
- // Check for UPPER_SNAKE_CASE
211
- if (!isUpperSnakeCase(name)) {
290
+ // Check for UPPER_SNAKE_CASE. Jest mock handles (`x as jest.Mock<…>`)
291
+ // are exempt: they are mutable test doubles, not immutable config, so
292
+ // the `mockedX` idiom is intentional. The exemption gates only this
293
+ // rename check — the `as const` logic above is untouched.
294
+ if (!isUpperSnakeCase(name) && !isJestMockCast(init)) {
212
295
  const newName = name
213
296
  .replace(/([A-Z])/g, '_$1')
214
297
  .toUpperCase()
215
298
  .replace(/^_/, '');
299
+ const idNode = declaration.id;
216
300
  context.report({
217
301
  node: declaration,
218
302
  messageId: 'upperSnakeCase',
@@ -221,12 +305,50 @@ exports.default = (0, createRule_1.createRule)({
221
305
  suggestedName: newName,
222
306
  },
223
307
  fix(fixer) {
224
- if (typeAnnotation) {
225
- return fixer.replaceText(declaration, `${newName}${typeText} = ${initText}`);
308
+ // Resolve the declared variable so the rename can rewrite the
309
+ // declaration AND every reference together. Renaming only the
310
+ // declaration id (the previous behavior) left every use site
311
+ // bound to a now-undefined name — `--fix` exited 0 while
312
+ // silently corrupting working code (Issue #1313, same defect
313
+ // class as #1256).
314
+ const declaredVariable = context
315
+ .getDeclaredVariables(declaration)
316
+ .find((variable) => variable.name === name) ?? null;
317
+ // Cannot resolve the variable — never emit a partial rename.
318
+ if (!declaredVariable) {
319
+ return null;
320
+ }
321
+ // Exported symbols with in-file use sites are cross-file
322
+ // contracts whose importers a single-file fixer cannot reach;
323
+ // rewriting the local sites alone would still leave the export
324
+ // renamed and importers broken. Report-only. (A bare exported
325
+ // declaration with no extra references keeps the historical
326
+ // rename behavior — nothing to orphan in-file.)
327
+ const hasExtraReferences = declaredVariable.references.some((ref) => ref.identifier !== idNode);
328
+ if (isExported && hasExtraReferences) {
329
+ return null;
330
+ }
331
+ // Suppress the fix when `newName` already binds something in a
332
+ // scope the rename would touch — a rename fixer must never
333
+ // change program semantics or shadow an existing binding.
334
+ if (renameWouldCollide(declaredVariable, newName)) {
335
+ return null;
226
336
  }
227
- else {
228
- return fixer.replaceText(declaration.id, newName);
337
+ // Rewrite the declaration id (preserving any type annotation,
338
+ // whose range is part of the id node) plus every reference.
339
+ const fixes = [
340
+ fixer.replaceText(idNode, typeAnnotation ? `${newName}${typeText}` : newName),
341
+ ];
342
+ for (const ref of declaredVariable.references) {
343
+ // The declaration write reference is the id node itself and
344
+ // is already handled above. Skipping it also avoids emitting
345
+ // overlapping fix ranges, which ESLint rejects.
346
+ if (ref.identifier === idNode) {
347
+ continue;
348
+ }
349
+ fixes.push(fixer.replaceText(ref.identifier, newName));
229
350
  }
351
+ return fixes;
230
352
  },
231
353
  });
232
354
  }
@@ -43,6 +43,29 @@ const SESSION_MATCHER = {
43
43
  function isDirectiveComment(comment) {
44
44
  return DIRECTIVE_PREFIX.test(comment.value.trim());
45
45
  }
46
+ /**
47
+ * Pointer phrasing by which a directive DEFERS its rationale to an adjacent
48
+ * comment: "see above", "per the note below", "as noted in the comment".
49
+ * Up to two intervening words ("the note", "the preceding") are tolerated so
50
+ * natural phrasings still register as deferrals.
51
+ */
52
+ const DEFERRAL_PATTERN = /\b(?:see|per|as)\s+(?:\w+\s+){0,2}(?:above|below|note|comment|preceding)\b/i;
53
+ /**
54
+ * A directive defers to its preceding comment only when its own `--` text is a
55
+ * mere pointer to that comment ("see above") or carries no substantive words at
56
+ * all (a bare "^" gesture). This is the discriminating signal for #1296's
57
+ * split-justification support: a directive that already states a substantive,
58
+ * self-contained reason owns its rationale outright, so an unrelated docblock or
59
+ * line comment that merely sits above it — documenting the declaration, not the
60
+ * suppression — must never contribute its incidental harness keywords (#1312).
61
+ */
62
+ function defersToPreceding(justification) {
63
+ const text = justification.trim();
64
+ if (DEFERRAL_PATTERN.test(text)) {
65
+ return true;
66
+ }
67
+ return text.replace(/[^a-z]/gi, '') === '';
68
+ }
46
69
  /**
47
70
  * Isolates the justification text of a directive comment: everything after the
48
71
  * first `--` separator, spanning every line of a multi-line block body. The
@@ -109,12 +132,16 @@ exports.noHarnessCoupledDisables = (0, createRule_1.createRule)({
109
132
  }
110
133
  // Split-justification style: an immediately-adjacent preceding
111
134
  // non-directive comment (no intervening blank line or code) is part
112
- // of the directive's rationale when the directive defers to it.
135
+ // of the directive's rationale ONLY when the directive defers to it.
136
+ // Without the deferral gate, an unrelated docblock above the
137
+ // declaration bleeds its incidental harness words into a directive
138
+ // that already carries a self-contained code-level reason (#1312).
113
139
  let scanned = justification;
114
140
  const previous = comments[index - 1];
115
141
  if (previous &&
116
142
  !isDirectiveComment(previous) &&
117
- comment.loc.start.line - previous.loc.end.line <= 1) {
143
+ comment.loc.start.line - previous.loc.end.line <= 1 &&
144
+ defersToPreceding(justification)) {
118
145
  scanned = `${previous.value}\n${scanned}`;
119
146
  }
120
147
  const matchedTerm = findHarnessTerm(scanned);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.19.8",
3
+ "version": "1.19.10",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.19.10",
4
+ "date": "2026-07-17T10:33:03.696Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1313
11
+ ],
12
+ "summary": "scope-aware upperSnakeCase autofix + exempt jest mock handles (closes #1313)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.19.9",
18
+ "date": "2026-07-17T01:27:12.035Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-harness-coupled-disables",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1312
25
+ ],
26
+ "summary": "only merge preceding comment when directive defers to it (closes #1312)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.19.8",
4
32
  "date": "2026-07-17T00:25:07.461Z",