@blumintinc/eslint-plugin-blumint 1.20.129 → 1.20.131

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.129',
226
+ version: '1.20.131',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -5,19 +5,82 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.DYNAMIC_RULES_LABEL = exports.REQUIRE_DYNAMIC_FIREBASE_IMPORTS_RULE = exports.RULE_NAME = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
+ const utils_1 = require("@typescript-eslint/utils");
8
9
  const createRule_1 = require("../utils/createRule");
9
10
  exports.RULE_NAME = 'enforce-dynamic-file-naming';
10
11
  const ENFORCE_DYNAMIC_IMPORTS_RULE = '@blumintinc/blumint/enforce-dynamic-imports';
11
12
  exports.REQUIRE_DYNAMIC_FIREBASE_IMPORTS_RULE = '@blumintinc/blumint/require-dynamic-firebase-imports';
12
13
  exports.DYNAMIC_RULES_LABEL = `${ENFORCE_DYNAMIC_IMPORTS_RULE} or ${exports.REQUIRE_DYNAMIC_FIREBASE_IMPORTS_RULE}`;
13
- const SHORTHAND_DISABLE_NEXT_LINE = /\bednl\b/;
14
- const SHORTHAND_DISABLE_LINE = /\bedl\b/;
15
14
  const DISABLE_NEXT_LINE_TOKEN = 'eslint-disable-next-line';
16
15
  const DISABLE_LINE_TOKEN = 'eslint-disable-line';
17
- const DISABLE_BLOCK_PATTERN = /\beslint-disable\b(?!-)/;
18
- const disabledRuleNameFrom = (commentText) => {
19
- const mentionsEnforceDynamicImports = commentText.includes(ENFORCE_DYNAMIC_IMPORTS_RULE);
20
- const mentionsRequireDynamicFirebaseImports = commentText.includes(exports.REQUIRE_DYNAMIC_FIREBASE_IMPORTS_RULE);
16
+ const DISABLE_TOKEN = 'eslint-disable';
17
+ /**
18
+ * ESLint splits a directive comment on ` -- ` and treats the tail as a human
19
+ * justification, never as part of the rule list. A rule named only in the tail
20
+ * is prose, so it must not read as a bypass.
21
+ */
22
+ const JUSTIFICATION_SEPARATOR = /\s-{2,}\s/;
23
+ /**
24
+ * Mirrors ESLint's own `directivesPattern`: the directive has to be the FIRST
25
+ * token of the comment, followed by whitespace or the end of the comment.
26
+ * `// see eslint-disable-next-line <rule>` is prose to ESLint and suppresses
27
+ * nothing, so it cannot count as an acknowledged exception here either.
28
+ *
29
+ * The alternatives are ordered longest-first because `eslint-disable` would
30
+ * otherwise shadow the two suffixed spellings.
31
+ */
32
+ const DIRECTIVE_PATTERN = new RegExp(`^(${DISABLE_NEXT_LINE_TOKEN}|${DISABLE_LINE_TOKEN}|${DISABLE_TOKEN})(?:\\s|$)`);
33
+ /**
34
+ * The only two directives ESLint honors inside a `//` comment. A bare
35
+ * `// eslint-disable <rule>` is inert — ESLint parses `eslint-disable` only out
36
+ * of a block comment — so blessing it would hand a reviewer a documented
37
+ * exception while the sibling rule keeps failing CI.
38
+ */
39
+ const LINE_COMMENT_DIRECTIVES = new Set([
40
+ DISABLE_NEXT_LINE_TOKEN,
41
+ DISABLE_LINE_TOKEN,
42
+ ]);
43
+ /**
44
+ * The rule names a comment actually disables, or `null` when ESLint would not
45
+ * read the comment as a disable directive at all.
46
+ */
47
+ const disabledRuleNamesFrom = (comment) => {
48
+ const justification = JUSTIFICATION_SEPARATOR.exec(comment.value);
49
+ const directivePart = (justification ? comment.value.slice(0, justification.index) : comment.value).trim();
50
+ const match = DIRECTIVE_PATTERN.exec(directivePart);
51
+ if (!match) {
52
+ return null;
53
+ }
54
+ const directiveText = match[1];
55
+ if (comment.type !== utils_1.AST_TOKEN_TYPES.Block &&
56
+ !LINE_COMMENT_DIRECTIVES.has(directiveText)) {
57
+ return null;
58
+ }
59
+ // ESLint rejects a multi-line `eslint-disable-line` outright and reports the
60
+ // comment as a problem instead of applying it.
61
+ if (directiveText === DISABLE_LINE_TOKEN &&
62
+ comment.loc.start.line !== comment.loc.end.line) {
63
+ return null;
64
+ }
65
+ return directivePart
66
+ .slice(directiveText.length)
67
+ .split(',')
68
+ .map((ruleName) => ruleName.trim().replace(/^(['"])(.*)\1$/s, '$2'))
69
+ .filter((ruleName) => ruleName.length > 0);
70
+ };
71
+ /**
72
+ * The rule has to be named explicitly. An unnamed blanket disable is not a
73
+ * counter-example: it silences this rule too, so no report is observable on
74
+ * such a file either way.
75
+ */
76
+ const disabledRuleNameFrom = (comment) => {
77
+ const disabledRuleNames = disabledRuleNamesFrom(comment);
78
+ if (disabledRuleNames === null) {
79
+ return null;
80
+ }
81
+ const disabled = new Set(disabledRuleNames);
82
+ const mentionsEnforceDynamicImports = disabled.has(ENFORCE_DYNAMIC_IMPORTS_RULE);
83
+ const mentionsRequireDynamicFirebaseImports = disabled.has(exports.REQUIRE_DYNAMIC_FIREBASE_IMPORTS_RULE);
21
84
  if (mentionsEnforceDynamicImports && mentionsRequireDynamicFirebaseImports) {
22
85
  return exports.DYNAMIC_RULES_LABEL;
23
86
  }
@@ -59,16 +122,8 @@ exports.default = (0, createRule_1.createRule)({
59
122
  const sourceCode = context.getSourceCode();
60
123
  const comments = sourceCode.getAllComments();
61
124
  for (const comment of comments) {
62
- const commentText = comment.value.trim();
63
- const disabledRuleNameForComment = disabledRuleNameFrom(commentText);
64
- const disablesTargetRule = disabledRuleNameForComment !== null;
65
- const inlineDisable = (commentText.includes(DISABLE_NEXT_LINE_TOKEN) ||
66
- commentText.includes(DISABLE_LINE_TOKEN) ||
67
- SHORTHAND_DISABLE_NEXT_LINE.test(commentText) ||
68
- SHORTHAND_DISABLE_LINE.test(commentText)) &&
69
- disablesTargetRule;
70
- const blockDisable = DISABLE_BLOCK_PATTERN.test(commentText) && disablesTargetRule;
71
- if (inlineDisable || blockDisable) {
125
+ const disabledRuleNameForComment = disabledRuleNameFrom(comment);
126
+ if (disabledRuleNameForComment !== null) {
72
127
  foundDisableDirective = true;
73
128
  disabledRuleName = disabledRuleNameForComment;
74
129
  break;
@@ -22,5 +22,23 @@ type Options = [
22
22
  */
23
23
  export declare const DEFAULT_IGNORED_LIBRARIES: string[];
24
24
  export declare const DEFAULT_INTERNAL_PREFIXES: string[];
25
+ /**
26
+ * Builds an O(1) + glob matcher from a list of library patterns.
27
+ *
28
+ * With `coverSubpaths`, a non-glob entry stands for the package *and*
29
+ * everything published under it. A package's subpath entry point is the same
30
+ * dependency as its root — `fast-deep-equal/es6` is upstream's documented ESM
31
+ * build, and the spelling `fast-deep-equal-over-microdiff` steers code toward —
32
+ * so exempting the root while enforcing the subpath left the pair of rules
33
+ * jointly unsatisfiable (#1845).
34
+ *
35
+ * The boundary is `entry + '/'`, never a bare substring: `fast-deep-equal-extra`
36
+ * is a different package on the registry and stays enforced. Glob entries keep
37
+ * their minimatch semantics untouched, since a pattern already says how far it
38
+ * reaches.
39
+ */
40
+ export declare const buildLibraryMatcher: (list: string[], { coverSubpaths }: {
41
+ coverSubpaths: boolean;
42
+ }) => (source: string) => boolean;
25
43
  declare const _default: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"dynamicImportRequired", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
26
44
  export default _default;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_INTERNAL_PREFIXES = exports.DEFAULT_IGNORED_LIBRARIES = exports.RULE_NAME = void 0;
3
+ exports.buildLibraryMatcher = exports.DEFAULT_INTERNAL_PREFIXES = exports.DEFAULT_IGNORED_LIBRARIES = exports.RULE_NAME = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const minimatch_1 = require("minimatch");
6
6
  const module_1 = require("module");
@@ -43,6 +43,42 @@ exports.DEFAULT_IGNORED_LIBRARIES = [
43
43
  'fast-deep-equal', // fast-deep-equal-over-microdiff, for files already on upstream
44
44
  ];
45
45
  exports.DEFAULT_INTERNAL_PREFIXES = ['src/', 'functions/'];
46
+ /**
47
+ * Builds an O(1) + glob matcher from a list of library patterns.
48
+ *
49
+ * With `coverSubpaths`, a non-glob entry stands for the package *and*
50
+ * everything published under it. A package's subpath entry point is the same
51
+ * dependency as its root — `fast-deep-equal/es6` is upstream's documented ESM
52
+ * build, and the spelling `fast-deep-equal-over-microdiff` steers code toward —
53
+ * so exempting the root while enforcing the subpath left the pair of rules
54
+ * jointly unsatisfiable (#1845).
55
+ *
56
+ * The boundary is `entry + '/'`, never a bare substring: `fast-deep-equal-extra`
57
+ * is a different package on the registry and stays enforced. Glob entries keep
58
+ * their minimatch semantics untouched, since a pattern already says how far it
59
+ * reaches.
60
+ */
61
+ const buildLibraryMatcher = (list, { coverSubpaths }) => {
62
+ const exactSet = new Set();
63
+ const subpathPrefixes = [];
64
+ const globs = [];
65
+ for (const lib of list) {
66
+ const mm = new minimatch_1.Minimatch(lib);
67
+ if (mm.hasMagic()) {
68
+ globs.push(mm);
69
+ }
70
+ else {
71
+ exactSet.add(lib);
72
+ if (coverSubpaths) {
73
+ subpathPrefixes.push(lib.endsWith('/') ? lib : `${lib}/`);
74
+ }
75
+ }
76
+ }
77
+ return (source) => exactSet.has(source) ||
78
+ subpathPrefixes.some((prefix) => source.startsWith(prefix)) ||
79
+ globs.some((mm) => mm.match(source));
80
+ };
81
+ exports.buildLibraryMatcher = buildLibraryMatcher;
46
82
  // Pre-built set of Node.js core module names for O(1) lookup.
47
83
  const NODE_BUILTINS = new Set(module_1.builtinModules);
48
84
  // Returns true for any source that resolves to a Node builtin: bare name
@@ -106,25 +142,22 @@ exports.default = (0, createRule_1.createRule)({
106
142
  // When `libraries` is absent, enforce-by-default mode applies:
107
143
  // all external imports are flagged unless in `ignoredLibraries`.
108
144
  const isWhitelistMode = libraries !== undefined;
109
- // Build an O(1) + glob matcher from a list of library patterns.
110
- const buildMatcher = (list) => {
111
- const exactSet = new Set();
112
- const globs = [];
113
- for (const lib of list) {
114
- const mm = new minimatch_1.Minimatch(lib);
115
- if (mm.hasMagic()) {
116
- globs.push(mm);
117
- }
118
- else {
119
- exactSet.add(lib);
120
- }
121
- }
122
- return (source) => exactSet.has(source) || globs.some((mm) => mm.match(source));
123
- };
124
145
  // In whitelist mode, `libraries` is defined (checked above). In
125
146
  // enforce-by-default mode, `ignoredLibraries` is used instead.
126
- const isListedInWhitelist = buildMatcher(libraries ?? []);
127
- const isIgnoredLibrary = buildMatcher(ignoredLibraries);
147
+ //
148
+ // Subpath covering is asymmetric between the two lists because the lists
149
+ // point in opposite directions. Widening `ignoredLibraries` only ever
150
+ // REMOVES reports, so it can safely absorb a package's subpath entry
151
+ // points. Widening `libraries` would ADD reports — a consumer who listed
152
+ // `pkg` to restore pre-1.16.0 behaviour would start failing on `pkg/sub` —
153
+ // so the whitelist keeps exact + glob semantics, and consumers who do want
154
+ // the subpaths enforced spell that as a glob (`pkg/**`), which still works.
155
+ const isListedInWhitelist = (0, exports.buildLibraryMatcher)(libraries ?? [], {
156
+ coverSubpaths: false,
157
+ });
158
+ const isIgnoredLibrary = (0, exports.buildLibraryMatcher)(ignoredLibraries, {
159
+ coverSubpaths: true,
160
+ });
128
161
  // A source is external only if it looks like an npm package specifier AND
129
162
  // is not a known-internal path. Node builtins and configured internal
130
163
  // prefixes (e.g. src/, functions/) are excluded to avoid false positives
@@ -1,4 +1,4 @@
1
1
  import { TSESLint } from '@typescript-eslint/utils';
2
- type MessageIds = 'useGlobalConstant' | 'extractDefaultToGlobalConstant';
2
+ type MessageIds = 'useGlobalConstant' | 'extractDefaultToGlobalConstant' | 'declareMemoDependency';
3
3
  export declare const enforceGlobalConstants: TSESLint.RuleModule<MessageIds, [], TSESLint.RuleListener>;
4
4
  export {};
@@ -5,6 +5,86 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const shebang_1 = require("../utils/shebang");
7
7
  const ASTHelpers_1 = require("../utils/ASTHelpers");
8
+ /**
9
+ * Scope kinds whose bindings are established once per module evaluation:
10
+ * globals, imports and module-level declarations. A literal reading one of those
11
+ * can still be hoisted verbatim, because the name it reads is in scope at module
12
+ * level too.
13
+ */
14
+ const MODULE_LEVEL_SCOPE_TYPES = new Set(['global', 'module']);
15
+ /**
16
+ * True when `inner` lies entirely inside `outer`'s source range.
17
+ */
18
+ function isRangeWithin(inner, outer) {
19
+ return inner[0] >= outer[0] && inner[1] <= outer[1];
20
+ }
21
+ /**
22
+ * True when a reference appears purely in type position (an annotation, or the
23
+ * target of an `as`/`satisfies`). Types erase at compile time, so such a name
24
+ * neither blocks hoisting nor belongs in a dependency array. The flags are read
25
+ * defensively: an analyzer that omits them leaves the reference classified as a
26
+ * value, which keeps the conservative answer.
27
+ */
28
+ function isTypeOnlyReference(reference) {
29
+ const flags = reference;
30
+ return flags.isTypeReference === true && flags.isValueReference === false;
31
+ }
32
+ /**
33
+ * True when a reference names a value that can differ between renders, i.e. one
34
+ * bound INSIDE the module and OUTSIDE the memo callback: a prop, a local, a
35
+ * destructured value, another hook's result.
36
+ *
37
+ * Everything else leaves hoisting available. An unresolved name is an ambient
38
+ * global. A module- or global-scoped binding is fixed for the module's lifetime
39
+ * and is equally visible from module scope. A binding whose own scope sits
40
+ * inside the callback — the callback's parameters, its locals, a nested
41
+ * function's locals — is created by the callback rather than closed over.
42
+ */
43
+ function isRenderScopeReference(reference, callbackRange) {
44
+ const variable = reference.resolved;
45
+ if (!variable) {
46
+ return false;
47
+ }
48
+ if (MODULE_LEVEL_SCOPE_TYPES.has(variable.scope.type)) {
49
+ return false;
50
+ }
51
+ return !isRangeWithin(variable.scope.block.range, callbackRange);
52
+ }
53
+ /**
54
+ * The first render-scope value a memo callback reads, in source order, or null
55
+ * when it reads none.
56
+ *
57
+ * Answered from RESOLVED scope references rather than identifier names, so
58
+ * shadowing, destructuring and imports are accounted for exactly as the scope
59
+ * analyzer sees them. The whole callback is the unit of analysis, not just the
60
+ * returned literal: `const debounce = delay * 2; return { debounce };` closes
61
+ * over `delay` just as `return { debounce: delay }` does, and naming the
62
+ * callback-local `debounce` would prescribe a dependency that does not exist
63
+ * outside the callback.
64
+ */
65
+ function findRenderScopeDependency(callbackScope, callbackRange) {
66
+ let earliest = null;
67
+ const pending = [callbackScope];
68
+ while (pending.length > 0) {
69
+ const current = pending.pop();
70
+ for (const reference of current.references) {
71
+ if (isTypeOnlyReference(reference)) {
72
+ continue;
73
+ }
74
+ if (!isRenderScopeReference(reference, callbackRange)) {
75
+ continue;
76
+ }
77
+ // Source order rather than traversal order, so the reported name does not
78
+ // depend on how the scope tree happens to be walked.
79
+ if (!earliest ||
80
+ reference.identifier.range[0] < earliest.identifier.range[0]) {
81
+ earliest = reference;
82
+ }
83
+ }
84
+ pending.push(...current.childScopes);
85
+ }
86
+ return earliest ? earliest.identifier.name : null;
87
+ }
8
88
  exports.enforceGlobalConstants = (0, createRule_1.createRule)({
9
89
  name: 'enforce-global-constants',
10
90
  meta: {
@@ -18,6 +98,7 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
18
98
  messages: {
19
99
  useGlobalConstant: 'Object literal returned from useMemo with empty dependencies creates a new reference every render without providing memoization benefits → this wastes memory and misleads readers into thinking the value is computed → move the object to a module-level constant (e.g., const OPTIONS = { ... } as const;).',
20
100
  extractDefaultToGlobalConstant: 'Inline default value in destructuring creates a new reference on every render → this causes unnecessary re-renders in child components due to unstable identity → extract the default to a module-level constant (e.g., const DEFAULT_OPTIONS = { ... } as const;).',
101
+ declareMemoDependency: 'Object literal returned from useMemo reads "{{name}}" from the surrounding render scope while declaring an empty dependency array → the memo keeps the "{{name}}" captured on the first render and never recomputes, so the object silently goes stale, and it cannot be hoisted to a module-level constant because "{{name}}" exists only during a render → declare "{{name}}" (and every other render-scope value the callback reads) in the dependency array, or drop the useMemo if the object is meant to be constant.',
21
102
  },
22
103
  },
23
104
  defaultOptions: [],
@@ -169,6 +250,19 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
169
250
  }
170
251
  return { kind: 'free' };
171
252
  }
253
+ /**
254
+ * The first render-scope value the memo callback reads, or null when it
255
+ * reads none and the literal is therefore hoistable as written.
256
+ */
257
+ function getRenderScopeDependency(callback) {
258
+ const callbackScope = sourceCode.scopeManager?.acquire(callback);
259
+ if (!callbackScope) {
260
+ // Without scope analysis nothing can be shown to be closed over, so the
261
+ // literal keeps the hoisting report the rule has always emitted.
262
+ return null;
263
+ }
264
+ return findRenderScopeDependency(callbackScope, callback.range);
265
+ }
172
266
  function buildInitializerText(initText) {
173
267
  const needsAsConst = /^(?:true|false|-?\d|\[|\{|[`'"])/.test(initText) &&
174
268
  !/\bas const\b/.test(initText);
@@ -331,15 +425,31 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
331
425
  if (returnValue.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
332
426
  actualReturnValue = returnValue.expression;
333
427
  }
334
- if (actualReturnValue.type === utils_1.AST_NODE_TYPES.ObjectExpression ||
335
- (actualReturnValue.type === utils_1.AST_NODE_TYPES.ArrayExpression &&
428
+ if (actualReturnValue.type !== utils_1.AST_NODE_TYPES.ObjectExpression &&
429
+ !(actualReturnValue.type === utils_1.AST_NODE_TYPES.ArrayExpression &&
336
430
  actualReturnValue.elements.some((element) => element !== null &&
337
431
  element.type === utils_1.AST_NODE_TYPES.ObjectExpression))) {
432
+ return;
433
+ }
434
+ // An empty dependency array means the author DECLARED no dependencies,
435
+ // not that there are none. When the callback closes over a render-scope
436
+ // value, hoisting the literal to module scope does not compile — the
437
+ // name it reads exists only during a render — so prescribing a global
438
+ // constant is advice that cannot be followed. The reachable remedy is
439
+ // the omitted dependency, which is what the split below names.
440
+ const renderScopeDependency = getRenderScopeDependency(callback);
441
+ if (renderScopeDependency !== null) {
338
442
  context.report({
339
443
  node,
340
- messageId: 'useGlobalConstant',
444
+ messageId: 'declareMemoDependency',
445
+ data: { name: renderScopeDependency },
341
446
  });
447
+ return;
342
448
  }
449
+ context.report({
450
+ node,
451
+ messageId: 'useGlobalConstant',
452
+ });
343
453
  },
344
454
  VariableDeclaration(node) {
345
455
  const relevantDeclarators = node.declarations.filter((d) => d.id.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
@@ -39,6 +39,18 @@ const APPROVED_REEXPORT_SOURCES = new Set(['constants', 'constants/index']);
39
39
  * is.
40
40
  */
41
41
  const normalizeSpecifier = (source) => source.replace(/^@\/|^src\//, '').replace(/^(\.\/|\.\.\/)+/, '');
42
+ const ASSERTION_NODE_TYPES = new Set([
43
+ utils_1.AST_NODE_TYPES.TSAsExpression,
44
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
45
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
46
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
47
+ ]);
48
+ const isTypeAssertion = (node) => ASSERTION_NODE_TYPES.has(node.type);
49
+ /**
50
+ * The expression an assertion — or a stack of them, since they compose — is
51
+ * written onto.
52
+ */
53
+ const unwrapTypeAssertions = (node) => isTypeAssertion(node) ? unwrapTypeAssertions(node.expression) : node;
42
54
  const toPosixPath = (filePath) => filePath.replace(/\\/g, '/');
43
55
  const ensureRelativeSpecifier = (specifier) => specifier.startsWith('.') ? specifier : `./${specifier}`;
44
56
  const isWindowsDrivePath = (filePath) => /^[A-Za-z]:[\\/]/.test(filePath);
@@ -387,6 +399,17 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
387
399
  if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
388
400
  return isValidQueryKeyUsage(node.expression);
389
401
  }
402
+ // The same argument, and it holds more strongly for an assertion: `as
403
+ // const`, `satisfies string`, `!` and `<string>KEY` are erased before
404
+ // anything runs, so what they evaluate to is the very key they wrap.
405
+ // Naming none of these types made an asserted key fall through to
406
+ // `return false`, and an alias records its initializer exactly as
407
+ // written — so a type spelled onto an alias of an approved constant
408
+ // withdrew the carve-out that same alias has without one, reporting a key
409
+ // `prefer-global-router-state-key` accepts (#1840).
410
+ if (isTypeAssertion(node)) {
411
+ return isValidQueryKeyUsage(node.expression);
412
+ }
390
413
  if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
391
414
  const importInfo = queryKeyImports.get(node.name);
392
415
  if (importInfo && isQueryKeysSource(importInfo.source)) {
@@ -452,6 +475,15 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
452
475
  * Check if a node contains string literals that should be reported
453
476
  */
454
477
  function containsInvalidStringLiteral(node) {
478
+ // A type says nothing about where a key came from, and a literal under
479
+ // one came from nowhere just the same. Answering on the assertion node
480
+ // instead of what it wraps let `'key' as const` — and an asserted operand
481
+ // of a concatenation or a ternary — pass for something other than a
482
+ // string literal, so the only bare-key detector this rule has never ran
483
+ // on it (#1842).
484
+ if (isTypeAssertion(node)) {
485
+ return containsInvalidStringLiteral(node.expression);
486
+ }
455
487
  // Direct string literal
456
488
  if (node.type === utils_1.AST_NODE_TYPES.Literal &&
457
489
  typeof node.value === 'string') {
@@ -517,6 +549,31 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
517
549
  }
518
550
  return null;
519
551
  }
552
+ /**
553
+ * The span a substituted constant is written over: the literal together
554
+ * with every assertion written onto it.
555
+ *
556
+ * The assertion is dropped rather than kept because it exists to shape the
557
+ * literal, and the literal is what leaves. `as const` is illegal on a
558
+ * reference (TS1355), so writing the constant *inside* the assertion would
559
+ * trade a report for a file that no longer compiles; the other notations
560
+ * survive that but only to restate a type the constant already has, since
561
+ * `queryKeys.ts` exports it narrowed. Replacing the whole span is therefore
562
+ * both the only uniformly compiling choice and the smaller edit to read.
563
+ *
564
+ * Null where a comment sits in the span the constant would cover: no
565
+ * rewrite can know what a comment beside a key meant, and deleting it is
566
+ * text this fixer does not own. The report then stands unfixed, which
567
+ * leaves the author holding both the key and the comment.
568
+ */
569
+ function substitutionSpanOf(keyExpression, staticKeyNode) {
570
+ if (keyExpression === staticKeyNode) {
571
+ return keyExpression;
572
+ }
573
+ return sourceCode.getCommentsInside(keyExpression).length === 0
574
+ ? keyExpression
575
+ : null;
576
+ }
520
577
  /**
521
578
  * The `QUERY_KEY_*` constant a key value names, or null when it names none.
522
579
  *
@@ -599,7 +656,18 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
599
656
  prop.key.name === 'key');
600
657
  // If key property exists, check its value
601
658
  if (keyProperty && keyProperty.value) {
602
- const keyValue = keyProperty.value;
659
+ const keyExpression = keyProperty.value;
660
+ // A type written onto the key is erased before anything runs,
661
+ // so what the arms below have to judge is the key underneath
662
+ // it. Dispatching on the node as written asked about the
663
+ // assertion — a node type neither arm names — so an invalid key
664
+ // escaped the rule altogether merely by carrying one, while the
665
+ // resolver behind `isValidQueryKeyUsage` had long since seen
666
+ // through it: detecting and resolving are separate paths, and
667
+ // widening one leaves the other exactly as it was (#1842).
668
+ // Reporting the unwrapped node puts the report on the same key
669
+ // the unasserted spelling reports.
670
+ const keyValue = unwrapTypeAssertions(keyExpression);
603
671
  // Check if it's a valid query key usage
604
672
  if (!isValidQueryKeyUsage(keyValue)) {
605
673
  // Check if it contains invalid string literals
@@ -609,12 +677,15 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
609
677
  const suggestedConstant = staticKey
610
678
  ? generateAutoFix(staticKey.text)
611
679
  : null;
680
+ const span = staticKey
681
+ ? substitutionSpanOf(keyExpression, staticKey.node)
682
+ : null;
612
683
  pendingReports.push({
613
684
  node: keyValue,
614
685
  messageId: 'enforceQueryKeyImport',
615
- substitution: staticKey && suggestedConstant
686
+ substitution: span && suggestedConstant
616
687
  ? {
617
- keyNode: staticKey.node,
688
+ keyNode: span,
618
689
  constant: suggestedConstant,
619
690
  scope: scopeOf(keyValue),
620
691
  }
@@ -8,6 +8,189 @@ const renameFixes_1 = require("../utils/renameFixes");
8
8
  const LOWERCASE_TYPES = ['ReactNode', 'JSX.Element'];
9
9
  // Types that should have uppercase variable names
10
10
  const UPPERCASE_TYPES = ['ComponentType', 'FC', 'FunctionComponent'];
11
+ /**
12
+ * `global-const-style` owns the NAME of a module-scope `const`, and the two
13
+ * rules cannot both be satisfied there: it demands UPPER_SNAKE_CASE, this rule
14
+ * demands a lowercase initial for `ReactNode`/`JSX.Element`. Every spelling
15
+ * reports under one or the other, so a consumer running both — they are both
16
+ * `'error'` in `recommended` — cannot write the line at all.
17
+ *
18
+ * Unexported, both renamers also autofix, so `--fix` oscillates (`element` ->
19
+ * `ELEMENT` -> `eLEMENT` -> `E_LEMENT` -> `e_LEMENT` -> …) until ESLint's
20
+ * ten-pass cap and writes the mangled identifier to disk (Issue #1846).
21
+ * EXPORTED, both withhold the rename — an exported name is a cross-file
22
+ * contract a single-file fixer cannot complete — so `--fix` is a no-op and the
23
+ * damage is only the unsatisfiable report pair (Issue #1847).
24
+ *
25
+ * The pair is resolved by this rule yielding: module-scope constant naming is
26
+ * `global-const-style`'s universal contract, while this rule's purpose —
27
+ * telling an element VALUE apart from a COMPONENT — is about local and
28
+ * parameter naming, where nothing competes with it. Do not re-open the
29
+ * carve-out without changing `global-const-style` in the same breath.
30
+ *
31
+ * GOVERNANCE FOLLOWS WHICH RULE REPORTS ON THE NAME, NOT WHICH ONE FIXES IT.
32
+ * #1846 drew the boundary at the fixer war and so excluded exports; that left
33
+ * the exported form unsatisfiable, because `global-const-style` withholds only
34
+ * its FIX there (#1700) and still emits `upperSnakeCase`. The predicate below
35
+ * therefore mirrors that rule's ACTUAL reporting gates, which is also why it
36
+ * cannot be simplified to "module-scope const" — it declines on several shapes,
37
+ * and yielding on one of those would leave the declaration governed by nothing:
38
+ *
39
+ * - `let`/`var`, and any non-module scope, are outside it entirely;
40
+ * - a declaration whose parent is neither `Program` nor an
41
+ * `ExportNamedDeclaration` (a block, a `for` head, a namespace body) never
42
+ * reaches its check;
43
+ * - an exported Next.js reserved name (`config`, `getStaticProps`, …) has its
44
+ * rename declined outright (#1257), so this rule keeps its report there —
45
+ * report-only, since its own fixer stands down for exports too;
46
+ * - a function value or a `memo`/`forwardRef` call makes it skip the whole
47
+ * declaration list — `const button: FC = () => …` is this rule's alone;
48
+ * - an absent initializer, a dynamic value, a binding alias and a
49
+ * `jest.Mock*` cast each silence its rename check.
50
+ *
51
+ * When any of that cannot be established the answer is `false`: keeping a
52
+ * report is recoverable, silently governing nothing is not.
53
+ */
54
+ const VALUE_WRAPPER_TYPES = new Set([
55
+ utils_1.AST_NODE_TYPES.TSAsExpression,
56
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
57
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
58
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
59
+ ]);
60
+ const isValueWrapper = (node) => VALUE_WRAPPER_TYPES.has(node.type);
61
+ const unwrapValueWrappers = (node) => {
62
+ let target = node;
63
+ while (isValueWrapper(target)) {
64
+ target = target.expression;
65
+ }
66
+ return target;
67
+ };
68
+ // `global-const-style` unwraps only `as`/`<T>` casts before classifying an
69
+ // initializer as dynamic or as a binding alias, so the mirror does the same.
70
+ const unwrapCasts = (node) => {
71
+ let target = node;
72
+ while (target.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
73
+ target.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
74
+ target = target.expression;
75
+ }
76
+ return target;
77
+ };
78
+ const COMPONENT_FACTORY_NAMES = new Set(['forwardRef', 'memo']);
79
+ const isComponentFactoryCall = (node) => {
80
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
81
+ return false;
82
+ }
83
+ const { callee } = node;
84
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
85
+ return COMPONENT_FACTORY_NAMES.has(callee.name);
86
+ }
87
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
88
+ !callee.computed &&
89
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
90
+ COMPONENT_FACTORY_NAMES.has(callee.property.name));
91
+ };
92
+ const isFunctionValue = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
93
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
94
+ const isDynamicValue = (node) => {
95
+ const target = unwrapCasts(node);
96
+ if (target.type === utils_1.AST_NODE_TYPES.CallExpression ||
97
+ target.type === utils_1.AST_NODE_TYPES.NewExpression ||
98
+ target.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
99
+ return true;
100
+ }
101
+ if (target.type === utils_1.AST_NODE_TYPES.ChainExpression) {
102
+ return isDynamicValue(target.expression);
103
+ }
104
+ if (target.type === utils_1.AST_NODE_TYPES.MemberExpression) {
105
+ return isDynamicValue(target.object);
106
+ }
107
+ return false;
108
+ };
109
+ const PRIMITIVE_VALUE_GLOBALS = new Set(['undefined', 'NaN', 'Infinity']);
110
+ const isBindingAlias = (node) => {
111
+ const target = unwrapCasts(node);
112
+ return (target.type === utils_1.AST_NODE_TYPES.Identifier &&
113
+ !PRIMITIVE_VALUE_GLOBALS.has(target.name));
114
+ };
115
+ const JEST_MOCK_TYPE_NAMES = new Set([
116
+ 'Mock',
117
+ 'MockedFunction',
118
+ 'Mocked',
119
+ 'MockedClass',
120
+ ]);
121
+ const isJestMockTypeReference = (typeAnnotation) => {
122
+ if (typeAnnotation.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
123
+ return false;
124
+ }
125
+ const { typeName } = typeAnnotation;
126
+ return (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName &&
127
+ typeName.left.type === utils_1.AST_NODE_TYPES.Identifier &&
128
+ typeName.left.name === 'jest' &&
129
+ typeName.right.type === utils_1.AST_NODE_TYPES.Identifier &&
130
+ JEST_MOCK_TYPE_NAMES.has(typeName.right.name));
131
+ };
132
+ const isJestMockCast = (node) => {
133
+ let current = node;
134
+ while (isValueWrapper(current)) {
135
+ if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
136
+ isJestMockTypeReference(current.typeAnnotation)) {
137
+ return true;
138
+ }
139
+ current = current.expression;
140
+ }
141
+ return false;
142
+ };
143
+ // Mirrors `global-const-style`'s own list. Next.js recognizes these export
144
+ // names by their literal identifier, so that rule declines the rename outright
145
+ // rather than breaking the framework contract (#1257) — nothing there governs
146
+ // the name, so this rule keeps its report. Only the EXPORT name matters to
147
+ // Next.js, exactly as the sibling gates it.
148
+ const NEXTJS_RESERVED_EXPORTS = new Set([
149
+ 'config',
150
+ 'getServerSideProps',
151
+ 'getStaticProps',
152
+ 'getStaticPaths',
153
+ 'getInitialProps',
154
+ 'middleware',
155
+ ]);
156
+ const isGlobalConstStyleGoverned = (declarator) => {
157
+ const declaration = declarator.parent;
158
+ if (!declaration ||
159
+ declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
160
+ declaration.kind !== 'const') {
161
+ return false;
162
+ }
163
+ // The sibling's own scope gate: module scope, whether written bare or behind
164
+ // an inline `export`. Exports are INCLUDED because it reports `upperSnakeCase`
165
+ // on them — it withholds only the FIX (#1700) — so leaving them out kept the
166
+ // pair unsatisfiable for `export const element: JSX.Element = …` (#1847).
167
+ const isExported = declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
168
+ if (declaration.parent?.type !== utils_1.AST_NODE_TYPES.Program && !isExported) {
169
+ return false;
170
+ }
171
+ if (isExported &&
172
+ declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
173
+ NEXTJS_RESERVED_EXPORTS.has(declarator.id.name)) {
174
+ return false;
175
+ }
176
+ // The function-value / component-factory skip is evaluated over the whole
177
+ // declaration LIST there, so `const a = () => {}, b = <div />;` exempts both.
178
+ const listSkipped = declaration.declarations.some((one) => {
179
+ if (one.id.type !== utils_1.AST_NODE_TYPES.Identifier || !one.init) {
180
+ return false;
181
+ }
182
+ const target = unwrapValueWrappers(one.init);
183
+ return isFunctionValue(target) || isComponentFactoryCall(target);
184
+ });
185
+ if (listSkipped) {
186
+ return false;
187
+ }
188
+ const { init } = declarator;
189
+ if (!init) {
190
+ return false;
191
+ }
192
+ return (!isDynamicValue(init) && !isBindingAlias(init) && !isJestMockCast(init));
193
+ };
11
194
  exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
12
195
  name: 'enforce-react-type-naming',
13
196
  meta: {
@@ -123,6 +306,12 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
123
306
  // Skip destructured variables
124
307
  if (isDestructured(id))
125
308
  return;
309
+ // Yield the name to `global-const-style` where it governs (Issue #1846).
310
+ // Both branches yield: its UPPER_SNAKE_CASE target already satisfies the
311
+ // component branch's "starts uppercase", so nothing is lost there, and
312
+ // the element branch is the one that cannot coexist with it at all.
313
+ if (isGlobalConstStyleGoverned(node))
314
+ return;
126
315
  const variableName = id.name;
127
316
  // Get the type annotation
128
317
  const typeAnnotation = id.typeAnnotation?.typeAnnotation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.129",
3
+ "version": "1.20.131",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,58 @@
1
1
  [
2
+ {
3
+ "version": "1.20.131",
4
+ "date": "2026-08-07T10:28:22.079Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-dynamic-imports",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1845
11
+ ],
12
+ "summary": "cover a package's subpaths from one ignoredLibraries entry (closes #1845)"
13
+ },
14
+ {
15
+ "name": "enforce-react-type-naming",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1846,
19
+ 1847
20
+ ],
21
+ "summary": "yield exported module-scope constants to global-const-style (closes #1847); yield module-scope constants to global-const-style (closes #1846)"
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "version": "1.20.130",
27
+ "date": "2026-08-07T08:38:15.008Z",
28
+ "rules": [
29
+ {
30
+ "name": "enforce-dynamic-file-naming",
31
+ "changeType": "fix",
32
+ "issues": [
33
+ 1843
34
+ ],
35
+ "summary": "honor only the disable directives ESLint itself honors (closes #1843)"
36
+ },
37
+ {
38
+ "name": "enforce-global-constants",
39
+ "changeType": "fix",
40
+ "issues": [
41
+ 1841
42
+ ],
43
+ "summary": "name the reachable remedy when a memo literal closes over render scope (closes #1841)"
44
+ },
45
+ {
46
+ "name": "enforce-querykey-ts",
47
+ "changeType": "fix",
48
+ "issues": [
49
+ 1840,
50
+ 1842
51
+ ],
52
+ "summary": "see through a type assertion at the report site (closes #1842); resolve an aliased key through a type assertion (closes #1840)"
53
+ }
54
+ ]
55
+ },
2
56
  {
3
57
  "version": "1.20.129",
4
58
  "date": "2026-08-07T06:47:26.731Z",