@blumintinc/eslint-plugin-blumint 1.20.154 → 1.20.156

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.154',
226
+ version: '1.20.156',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1806,6 +1806,49 @@ function moveSegment(segments, fromIndex, toIndex) {
1806
1806
  next.splice(toIndex < fromIndex ? toIndex : toIndex - 1, 0, moved.replace(/[ \t]+$/u, ''));
1807
1807
  return next;
1808
1808
  }
1809
+ const LINE_TERMINATORS = new Set(['\n', '\r', '\u2028', '\u2029']);
1810
+ /**
1811
+ * Whether text appended to `text` would start on a fresh line.
1812
+ *
1813
+ * Trailing spaces and tabs are skipped: a segment ends with the indentation that
1814
+ * belonged to whatever followed it, and that indentation still leaves an appended
1815
+ * segment on a line of its own.
1816
+ */
1817
+ function endsWithLineTerminator(text) {
1818
+ let cursor = text.length - 1;
1819
+ while (cursor >= 0 && (text[cursor] === ' ' || text[cursor] === '\t')) {
1820
+ cursor -= 1;
1821
+ }
1822
+ return cursor >= 0 && LINE_TERMINATORS.has(text[cursor]);
1823
+ }
1824
+ /**
1825
+ * The line break the source itself uses, so a separator restored below matches its
1826
+ * neighbours instead of mixing endings into a CRLF file.
1827
+ */
1828
+ function lineBreakOf(text) {
1829
+ const index = text.indexOf('\n');
1830
+ return index > 0 && text[index - 1] === '\r' ? '\r\n' : '\n';
1831
+ }
1832
+ /**
1833
+ * Concatenates reordered segments, restoring the statement separation the source
1834
+ * carried positionally.
1835
+ *
1836
+ * A segment runs up to where the next one began, so it normally already ends with the
1837
+ * line break that separated them. Two do not: the block's last segment stops at `}` or
1838
+ * at the end of the file, and a statement sharing a line with its predecessor never had
1839
+ * a break in front of it. Once a reordering moves such a segment away from the end,
1840
+ * appending the next segment directly runs two statements together — and where the
1841
+ * first ends in a `//` comment that is not a formatting blemish, since the appended
1842
+ * statement becomes comment text and vanishes from the program (#2023).
1843
+ *
1844
+ * Only the joins are separated. Nothing is appended after the final segment, whose
1845
+ * successor is the untouched text outside the replaced range.
1846
+ */
1847
+ function joinSegments(segments, lineBreak) {
1848
+ return segments.reduce((text, segment, index) => index === 0 || endsWithLineTerminator(text)
1849
+ ? text + segment
1850
+ : text + lineBreak + segment, '');
1851
+ }
1809
1852
  /**
1810
1853
  * Emits an entire reordering as one fix by permuting the block's text segments.
1811
1854
  *
@@ -1827,7 +1870,7 @@ function buildReorderFix(body, moves, parent, sourceCode, fixer) {
1827
1870
  if (first > last) {
1828
1871
  return null;
1829
1872
  }
1830
- return fixer.replaceTextRange([bounds[first], bounds[last + 1]], reordered.slice(first, last + 1).join(''));
1873
+ return fixer.replaceTextRange([bounds[first], bounds[last + 1]], joinSegments(reordered.slice(first, last + 1), lineBreakOf(sourceCode.getText())));
1831
1874
  }
1832
1875
  /**
1833
1876
  * A fix is emitted only for a reordering the detector scores at zero relocatable
@@ -569,32 +569,164 @@ function isOverloadedFunction(node) {
569
569
  }
570
570
  return false;
571
571
  }
572
- function isOverloadedTsDeclareFunction(node) {
572
+ /**
573
+ * The statement list that directly holds `node`, looking through `export`.
574
+ *
575
+ * Overload signatures and their implementation are siblings of one statement
576
+ * list — an overload set cannot span containers — so this is the only list worth
577
+ * reading. Walking outward instead would let a same-named function in an
578
+ * enclosing scope answer for one it cannot overload.
579
+ */
580
+ function siblingStatementsOf(node) {
581
+ const parent = node.parent;
582
+ if (!parent)
583
+ return undefined;
584
+ const container = parent.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
585
+ parent.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
586
+ ? parent.parent
587
+ : parent;
588
+ return container ? (0, lexicalScope_1.statementsOf)(container) : undefined;
589
+ }
590
+ const EMPTY_OVERLOAD_SET = { signatures: 0, implementations: 0 };
591
+ /**
592
+ * How many declaration-only signatures and how many implementations the
593
+ * container holding `node` declares under `node`'s own name, `node` included.
594
+ *
595
+ * Every statement container is read, not just `Program` and `TSModuleBlock`: a
596
+ * function body, a bare block and a `switch` case each bind a name just as
597
+ * effectively, so reading only the top level makes the DEPTH of an overload set
598
+ * decide whether it exists (the same defect as #1771).
599
+ */
600
+ function overloadSetOf(node) {
573
601
  const functionName = node.id?.name;
574
602
  if (!functionName)
603
+ return EMPTY_OVERLOAD_SET;
604
+ const statements = siblingStatementsOf(node);
605
+ if (!statements)
606
+ return EMPTY_OVERLOAD_SET;
607
+ let signatures = 0;
608
+ let implementations = 0;
609
+ for (const statement of statements) {
610
+ // `export function f(...)` is the same declaration one AST node deeper, and
611
+ // an overload set may export some of its members and not others.
612
+ const declaration = (0, lexicalScope_1.declarationOf)(statement);
613
+ if (declaration.type !== utils_1.AST_NODE_TYPES.TSDeclareFunction &&
614
+ declaration.type !== utils_1.AST_NODE_TYPES.FunctionDeclaration) {
615
+ continue;
616
+ }
617
+ if (declaration.id?.name !== functionName)
618
+ continue;
619
+ if (declaration.type === utils_1.AST_NODE_TYPES.TSDeclareFunction ||
620
+ !declaration.body) {
621
+ signatures += 1;
622
+ }
623
+ else {
624
+ implementations += 1;
625
+ }
626
+ }
627
+ return { signatures, implementations };
628
+ }
629
+ /**
630
+ * True when `node` is the IMPLEMENTATION of an overload set — a function with a
631
+ * body whose container also declares the same name as one or more
632
+ * declaration-only signatures.
633
+ *
634
+ * Its annotation is not a restatement of what the body returns: TypeScript
635
+ * checks each overload signature against the IMPLEMENTATION SIGNATURE, so the
636
+ * annotation is what the overloads are measured against. Inference yields the
637
+ * body's own type, which need not accept them — stripping `: void | string`
638
+ * from `function get(param?: string): void | string {}` infers `void` and makes
639
+ * the `: string` overload above it TS2394 (#2019).
640
+ *
641
+ * This carve-out ignores `allowOverloadedFunctions`. That option governs the
642
+ * declaration-only signatures, whose annotations are mandatory but carry no
643
+ * fixer, so reporting them costs nothing but a message. Here the report ships a
644
+ * fix that does not compile, which no option may ask for.
645
+ */
646
+ function isOverloadImplementation(node) {
647
+ if (!node.body)
575
648
  return false;
576
- let container = node.parent;
577
- while (container) {
578
- if (container.type === utils_1.AST_NODE_TYPES.Program ||
579
- container.type === utils_1.AST_NODE_TYPES.TSModuleBlock) {
580
- const declarations = container.body
581
- .map((statement) => {
582
- if (statement.type === utils_1.AST_NODE_TYPES.TSDeclareFunction) {
583
- return statement;
584
- }
585
- if (statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
586
- statement.declaration?.type === utils_1.AST_NODE_TYPES.TSDeclareFunction) {
587
- return statement.declaration;
588
- }
589
- return undefined;
590
- })
591
- .filter((value) => Boolean(value?.id?.name))
592
- .filter((decl) => decl.id.name === functionName);
593
- return declarations.length > 1;
649
+ return overloadSetOf(node).signatures > 0;
650
+ }
651
+ /**
652
+ * True when `node` is a declaration-only signature that belongs to an overload
653
+ * set: another signature declares the same name, or an implementation below it
654
+ * does. A lone `declare function f(): number;` overloads nothing, so it stays
655
+ * reportable.
656
+ */
657
+ function isOverloadedTsDeclareFunction(node) {
658
+ const { signatures, implementations } = overloadSetOf(node);
659
+ return signatures > 1 || implementations > 0;
660
+ }
661
+ /**
662
+ * A method's identity inside its class body. Overloads agree on all three
663
+ * components; `static f` and `f` merely spell the same name, and a computed key
664
+ * names nothing resolvable, so it yields nothing. The separator keeps a private
665
+ * `#log` from colliding with a string key `'#log'`.
666
+ */
667
+ function methodIdentityOf(node) {
668
+ if (node.computed)
669
+ return undefined;
670
+ if (node.key.type === utils_1.AST_NODE_TYPES.PrivateIdentifier) {
671
+ return `${node.static}\u0000private\u0000${node.key.name}`;
672
+ }
673
+ if (node.key.type !== utils_1.AST_NODE_TYPES.Identifier &&
674
+ node.key.type !== utils_1.AST_NODE_TYPES.Literal) {
675
+ return undefined;
676
+ }
677
+ const name = getNameFromIdentifierOrLiteral(node.key);
678
+ return name === undefined
679
+ ? undefined
680
+ : `${node.static}\u0000public\u0000${name}`;
681
+ }
682
+ /**
683
+ * The overload set a class method belongs to, counted over the members of its
684
+ * own class body. A member without a body is an overload signature
685
+ * (`TSEmptyBodyFunctionExpression`); the one member with a body is the
686
+ * implementation.
687
+ */
688
+ function classOverloadSetOf(node) {
689
+ const identity = methodIdentityOf(node);
690
+ const classBody = node.parent;
691
+ if (!identity || classBody?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
692
+ return EMPTY_OVERLOAD_SET;
693
+ }
694
+ let signatures = 0;
695
+ let implementations = 0;
696
+ for (const member of classBody.body) {
697
+ if (member.type !== utils_1.AST_NODE_TYPES.MethodDefinition)
698
+ continue;
699
+ if (methodIdentityOf(member) !== identity)
700
+ continue;
701
+ if (member.value.body) {
702
+ implementations += 1;
703
+ }
704
+ else {
705
+ signatures += 1;
594
706
  }
595
- container = container.parent;
596
707
  }
597
- return false;
708
+ return { signatures, implementations };
709
+ }
710
+ /**
711
+ * True when the method is the implementation of an overloaded class method, for
712
+ * the reason {@link isOverloadImplementation} gives — a class overload set is
713
+ * checked exactly as a function one is (#2019).
714
+ */
715
+ function isOverloadImplementationMethod(node) {
716
+ if (!node.value.body)
717
+ return false;
718
+ return classOverloadSetOf(node).signatures > 0;
719
+ }
720
+ /**
721
+ * True when the method is a declaration-only overload signature. A body-less
722
+ * method is legal only as part of an overload set, so it is exempt whenever the
723
+ * set holds anything else at all.
724
+ */
725
+ function isOverloadedClassMethodSignature(node) {
726
+ if (node.value.body)
727
+ return false;
728
+ const { signatures, implementations } = classOverloadSetOf(node);
729
+ return signatures > 1 || implementations > 0;
598
730
  }
599
731
  function isInterfaceOrAbstractMethodSignature(node) {
600
732
  if (node.type === utils_1.AST_NODE_TYPES.TSAbstractMethodDefinition)
@@ -1233,6 +1365,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1233
1365
  isReadonlyWideningReturnType(returnType) ||
1234
1366
  isAllowedVoidReturnType(returnType) ||
1235
1367
  isDecoratorFactory(node, returnType) ||
1368
+ isOverloadImplementation(node) ||
1236
1369
  (mergedOptions.allowRecursiveFunctions &&
1237
1370
  isRecursiveFunction(node)) ||
1238
1371
  isReturnTypeRequiredByRecursion(node)) {
@@ -1292,6 +1425,9 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1292
1425
  isReadonlyWideningReturnType(returnType) ||
1293
1426
  isAllowedVoidReturnType(returnType) ||
1294
1427
  isDecoratorFactory(node, returnType) ||
1428
+ isOverloadImplementationMethod(node) ||
1429
+ (mergedOptions.allowOverloadedFunctions &&
1430
+ isOverloadedClassMethodSignature(node)) ||
1295
1431
  (mergedOptions.allowAbstractMethodSignatures &&
1296
1432
  isInterfaceOrAbstractMethodSignature(node)) ||
1297
1433
  isReturnTypeRequiredByRecursion(node)) {
@@ -27,6 +27,7 @@ exports.preferNullishCoalescingBooleanProps = void 0;
27
27
  const utils_1 = require("@typescript-eslint/utils");
28
28
  const ts = __importStar(require("typescript"));
29
29
  const createRule_1 = require("../utils/createRule");
30
+ const replacementSegments_1 = require("../utils/replacementSegments");
30
31
  const BOOLEAN_PROP_REGEX = /^(is|has|should|can|will|do|does|did|was|were|enable|disable)/;
31
32
  function isBooleanType(type, checker) {
32
33
  if (type.isUnion()) {
@@ -531,6 +532,68 @@ function needsSelfParens(node, sourceCode) {
531
532
  }
532
533
  return !isParenthesized(node, sourceCode);
533
534
  }
535
+ /**
536
+ * Comments the rewrite would delete.
537
+ *
538
+ * The fix rebuilds the whole expression from `getText` of each operand, so every
539
+ * comment written inside the node but outside both operand ranges has no anchor
540
+ * in the replacement: the trivia around the `||` operator, and anything sitting
541
+ * inside source-level parentheses that the rebuild discards. Comments nested
542
+ * within an operand travel with that operand's own text and are never stranded.
543
+ */
544
+ function strandedComments(node, sourceCode) {
545
+ const enclosedBy = (operand) => (comment) => comment.range[0] >= operand.range[0] &&
546
+ comment.range[1] <= operand.range[1];
547
+ const inLeft = enclosedBy(node.left);
548
+ const inRight = enclosedBy(node.right);
549
+ return sourceCode
550
+ .getCommentsInside(node)
551
+ .filter((comment) => !inLeft(comment) && !inRight(comment));
552
+ }
553
+ function groupStrandedComments(node, sourceCode) {
554
+ const operator = sourceCode.getTokenAfter(node.left, {
555
+ filter: (token) => token.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === '||',
556
+ });
557
+ const groups = {
558
+ leading: [],
559
+ beforeOperator: [],
560
+ afterOperator: [],
561
+ trailing: [],
562
+ };
563
+ for (const comment of strandedComments(node, sourceCode)) {
564
+ if (comment.range[1] <= node.left.range[0]) {
565
+ groups.leading.push(comment);
566
+ }
567
+ else if (comment.range[0] >= node.right.range[1]) {
568
+ groups.trailing.push(comment);
569
+ }
570
+ else if (operator && comment.range[1] <= operator.range[0]) {
571
+ groups.beforeOperator.push(comment);
572
+ }
573
+ else {
574
+ groups.afterOperator.push(comment);
575
+ }
576
+ }
577
+ return groups;
578
+ }
579
+ /**
580
+ * `return`, `throw` and `yield` forbid a LineTerminator between themselves and
581
+ * their operand, so a carried comment that demands its own line cannot sit
582
+ * between one of them and the rewritten expression: a line comment there ends
583
+ * the statement through ASI, and so does a block comment carrying a line
584
+ * terminator, which the grammar reads AS a LineTerminator (#1963). Such a
585
+ * comment rides ahead of the keyword instead.
586
+ *
587
+ * The keyword token is the anchor rather than the start of its line because an
588
+ * insertion point immediately before a token is always a token boundary, while a
589
+ * line start can fall inside a multi-line template literal and would be written
590
+ * into the template's text.
591
+ */
592
+ const RESTRICTED_KEYWORDS = new Set(['return', 'throw', 'yield']);
593
+ function restrictedKeywordBefore(node, sourceCode) {
594
+ const before = sourceCode.getTokenBefore(node);
595
+ return before && RESTRICTED_KEYWORDS.has(before.value) ? before : null;
596
+ }
534
597
  exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
535
598
  name: 'prefer-nullish-coalescing-boolean-props',
536
599
  meta: {
@@ -582,10 +645,67 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
582
645
  right: rightText,
583
646
  },
584
647
  fix(fixer) {
585
- const replacement = `${parenthesizeLogical(leftText, node.left)} ?? ${parenthesizeLogical(rightText, node.right)}`;
586
- return fixer.replaceText(node, needsSelfParens(node, sourceCode)
587
- ? `(${replacement})`
588
- : replacement);
648
+ const groups = groupStrandedComments(node, sourceCode);
649
+ // Whether the replacement is parenthesized is decided by the
650
+ // expression's context exactly as it is without comments:
651
+ // parentheses are tokens, so letting a carried comment add them
652
+ // would make the comment change the emitted program.
653
+ const selfParens = needsSelfParens(node, sourceCode);
654
+ const text = sourceCode.getText();
655
+ const toSegment = (comment) => ({
656
+ text: text.slice(comment.range[0], comment.range[1]),
657
+ breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
658
+ });
659
+ // A carried comment's only anchor is the line the expression
660
+ // opens on, which can start mid-line.
661
+ const startLine = sourceCode.lines[node.loc.start.line - 1] ?? '';
662
+ const indent = /^[\t ]*/.exec(startLine)?.[0] ?? '';
663
+ // Inside parentheses a newline can never trigger ASI, so every
664
+ // comment can ride within the replacement on a line of its own.
665
+ // Without them, a leading comment that demands its own line
666
+ // moves ahead of a restricted keyword when one governs the
667
+ // expression; everywhere else a line break before the
668
+ // expression is inert.
669
+ const keyword = selfParens
670
+ ? null
671
+ : restrictedKeywordBefore(node, sourceCode);
672
+ const hoisted = keyword
673
+ ? groups.leading.filter(replacementSegments_1.requiresOwnLine)
674
+ : [];
675
+ const segments = [
676
+ ...groups.leading
677
+ .filter((comment) => !hoisted.includes(comment))
678
+ .map(toSegment),
679
+ {
680
+ text: parenthesizeLogical(leftText, node.left),
681
+ breakAfter: false,
682
+ },
683
+ ...groups.beforeOperator.map(toSegment),
684
+ { text: '??', breakAfter: false },
685
+ ...groups.afterOperator.map(toSegment),
686
+ {
687
+ text: parenthesizeLogical(rightText, node.right),
688
+ breakAfter: false,
689
+ },
690
+ ...groups.trailing.map(toSegment),
691
+ ];
692
+ if (selfParens) {
693
+ return fixer.replaceText(node, (0, replacementSegments_1.joinSegments)(segments, indent));
694
+ }
695
+ const body = (0, replacementSegments_1.joinSegmentBody)(segments, indent);
696
+ const trailing = segments[segments.length - 1].breakAfter
697
+ ? `\n${indent}`
698
+ : '';
699
+ const replacement = fixer.replaceText(node, `${body}${trailing}`);
700
+ if (!keyword || hoisted.length === 0) {
701
+ return replacement;
702
+ }
703
+ return [
704
+ fixer.insertTextBefore(keyword, hoisted
705
+ .map((comment) => `${text.slice(comment.range[0], comment.range[1])}\n${indent}`)
706
+ .join('')),
707
+ replacement,
708
+ ];
589
709
  },
590
710
  });
591
711
  }
@@ -34,6 +34,46 @@ function isStringLiteralType(member) {
34
34
  const { literal } = member;
35
35
  return (literal.type === utils_1.AST_NODE_TYPES.Literal && typeof literal.value === 'string');
36
36
  }
37
+ /**
38
+ * A declaration file is ambient in its entirety, so a declaration in one is
39
+ * subject to the ambient restriction even without an explicit `declare`.
40
+ */
41
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/i;
42
+ /**
43
+ * An ambient context accepts only a string, numeric or literal-enum `const`
44
+ * initializer (TS1254), so the derived `as const` array cannot be emitted there
45
+ * at all — `as const` or not, an array literal is rejected. Declining is the
46
+ * remedy rather than a different rewrite, because no legal rewrite exists in
47
+ * that position: the rule asks for importable runtime values and an ambient
48
+ * declaration is precisely the promise that no runtime value is emitted.
49
+ *
50
+ * `declare` on the OUTERMOST module declaration makes every level nested below
51
+ * it ambient too, so the whole ancestor chain is walked rather than just the
52
+ * enclosing block. A module named by a string literal (`module 'x' {}`) and a
53
+ * `global {}` augmentation are ambient by construction, with or without the
54
+ * modifier.
55
+ */
56
+ function isInAmbientContext(node, filename) {
57
+ if (DECLARATION_FILE.test(filename)) {
58
+ return true;
59
+ }
60
+ if (node.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
61
+ node.declare === true) {
62
+ return true;
63
+ }
64
+ let current = node.parent;
65
+ while (current) {
66
+ if (current.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration) {
67
+ if (current.declare === true ||
68
+ current.global === true ||
69
+ current.id.type === utils_1.AST_NODE_TYPES.Literal) {
70
+ return true;
71
+ }
72
+ }
73
+ current = current.parent;
74
+ }
75
+ return false;
76
+ }
37
77
  /**
38
78
  * Walk the scope chain upward collecting declared variable names. Used to skip
39
79
  * the autofix (report-only) when the derived `{TYPE}_VALUES` name is already
@@ -154,6 +194,9 @@ exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
154
194
  if (!members.every(isStringLiteralType)) {
155
195
  return;
156
196
  }
197
+ if (isInAmbientContext(node, context.getFilename())) {
198
+ return;
199
+ }
157
200
  const typeName = node.id.name;
158
201
  const constName = `${toUpperSnake(typeName)}_VALUES`;
159
202
  // The type alias is exported when wrapped in `export type X = ...`
@@ -62,6 +62,49 @@ const isInsideComponentMock = (node, componentModule) => {
62
62
  }
63
63
  return false;
64
64
  };
65
+ /**
66
+ * Name a declaration binds, for the forms a component is declared under:
67
+ * `const ImageOptimized = ...` (including `memo(...)`/`forwardRef(...)` around
68
+ * the body), `function ImageOptimized()` and `class ImageOptimized`. Anything
69
+ * else — an object property, a parameter — binds no declaration name the
70
+ * wrapper can be identified by, and treating it as one would exempt a mock
71
+ * factory keyed by the component name whatever module it stands in for.
72
+ */
73
+ const declaredNameOf = (node) => {
74
+ switch (node.type) {
75
+ case utils_1.AST_NODE_TYPES.VariableDeclarator:
76
+ return node.id.type === utils_1.AST_NODE_TYPES.Identifier ? node.id.name : null;
77
+ case utils_1.AST_NODE_TYPES.FunctionDeclaration:
78
+ case utils_1.AST_NODE_TYPES.FunctionExpression:
79
+ case utils_1.AST_NODE_TYPES.ClassDeclaration:
80
+ case utils_1.AST_NODE_TYPES.ClassExpression:
81
+ return node.id ? node.id.name : null;
82
+ default:
83
+ return null;
84
+ }
85
+ };
86
+ /**
87
+ * Names each local binding is exported under, so a wrapper declared as
88
+ * `Picture` and shipped as `export { Picture as ImageOptimized }` is still
89
+ * recognized as the component's own definition. A specifier carrying a `from`
90
+ * clause re-exports another module's binding and declares nothing here.
91
+ */
92
+ const exportedNamesByLocal = (program) => {
93
+ const exported = new Map();
94
+ for (const statement of program.body) {
95
+ if (statement.type !== utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
96
+ statement.source ||
97
+ statement.exportKind === 'type') {
98
+ continue;
99
+ }
100
+ for (const specifier of statement.specifiers) {
101
+ const names = exported.get(specifier.local.name) ?? new Set();
102
+ names.add(specifier.exported.name);
103
+ exported.set(specifier.local.name, names);
104
+ }
105
+ }
106
+ return exported;
107
+ };
65
108
  /**
66
109
  * A type-only specifier binds no value: it renders nothing, so it neither
67
110
  * bypasses the optimization pipeline nor can back a fix. The modifier lives
@@ -219,6 +262,40 @@ module.exports = (0, createRule_1.createRule)({
219
262
  * rule exists to centralize, not a violation of it.
220
263
  */
221
264
  const isComponentImplementationFile = moduleNameOf(context.getFilename()) === componentModule;
265
+ /**
266
+ * The wrapper is identified by the name the fixer would emit and by its
267
+ * module's name, which a component module's export shares by convention.
268
+ * Matching is exact so a distinct component whose name merely starts with
269
+ * it (`ImageOptimizedGallery`) stays reportable.
270
+ */
271
+ const isComponentName = (name) => name === COMPONENT_NAME || name === componentModule;
272
+ const exportedNames = exportedNamesByLocal(sourceCode.ast);
273
+ const definesComponent = (name) => {
274
+ if (isComponentName(name)) {
275
+ return true;
276
+ }
277
+ const aliases = exportedNames.get(name);
278
+ return !!aliases && [...aliases].some(isComponentName);
279
+ };
280
+ /**
281
+ * Whether the element sits inside the declaration of the component the fix
282
+ * points at. That declaration renders the image primitive by definition, so
283
+ * swapping it for the component makes the wrapper render itself: unbounded
284
+ * recursion, and a type error too, since the wrapper forwards only the
285
+ * props it destructured. The whole ancestry is walked because a helper
286
+ * nested inside the declaration is part of that implementation as well.
287
+ */
288
+ const isInsideComponentDefinition = (node) => {
289
+ let current = node.parent;
290
+ while (current) {
291
+ const declared = declaredNameOf(current);
292
+ if (declared && definesComponent(declared)) {
293
+ return true;
294
+ }
295
+ current = current.parent;
296
+ }
297
+ return false;
298
+ };
222
299
  return {
223
300
  // Handle JSX img elements
224
301
  JSXElement(node) {
@@ -228,7 +305,8 @@ module.exports = (0, createRule_1.createRule)({
228
305
  return;
229
306
  }
230
307
  if (isComponentImplementationFile ||
231
- isInsideComponentMock(node, componentModule)) {
308
+ isInsideComponentMock(node, componentModule) ||
309
+ isInsideComponentDefinition(node)) {
232
310
  return;
233
311
  }
234
312
  const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
@@ -85,6 +85,12 @@ export declare class ASTHelpers {
85
85
  * expression reads some other object.
86
86
  */
87
87
  private static classMemberNameReferencedBy;
88
+ /**
89
+ * The ECMA private names a class body declares, spelled as the graph spells
90
+ * them. A private name is scoped to the body that declares it, so this set
91
+ * is what an enclosing class must not mistake for its own members.
92
+ */
93
+ private static privateNamesDeclaredBy;
88
94
  private static walkChildNodes;
89
95
  static isNode(value: unknown): value is TSESTree.Node;
90
96
  static hasReturnStatement(node: TSESTree.Node): boolean;
@@ -423,7 +423,16 @@ class ASTHelpers {
423
423
  case 'ClassExpression': {
424
424
  // Traversal continues so `<ClassName>.<member>` statics stay visible,
425
425
  // but `this` no longer denotes the graphed instance.
426
- this.walkChildNodes(node, className, false, dependencies);
426
+ const nested = [];
427
+ this.walkChildNodes(node, className, false, nested);
428
+ // A nested class body SHADOWS the private names it declares: its `#q`
429
+ // is a member of that class, distinct from an enclosing class's `#q`,
430
+ // so reads of it constrain the nested layout rather than this one.
431
+ // Private names the nested class does not declare still resolve
432
+ // outward, so only the declared ones are dropped. Filtering as the
433
+ // recursion unwinds composes across any depth of nesting.
434
+ const shadowed = this.privateNamesDeclaredBy(node);
435
+ dependencies.push(...nested.filter((name) => !shadowed.has(name)));
427
436
  return;
428
437
  }
429
438
  case 'MemberExpression': {
@@ -447,6 +456,18 @@ class ASTHelpers {
447
456
  */
448
457
  static classMemberNameReferencedBy(node, className, isThisTheInstance) {
449
458
  const { object, property, computed } = node;
459
+ // A `#name` resolves LEXICALLY: it is a syntax error unless a class body
460
+ // enclosing the reference declares it, so the receiver it is read through
461
+ // cannot change which member it names. `other.#helper` and `this.#helper`
462
+ // reach the same member, which is why this branch precedes the receiver
463
+ // test that the dotted spellings need. Requiring `this`/<ClassName> here
464
+ // dropped the read a static initializer makes through another value of
465
+ // the same class, and with it the constraint that keeps the field's
466
+ // declaration above its reader (#2022). The `#` is part of the name so
467
+ // `#helper` and `helper` stay distinct members.
468
+ if (!computed && property?.type === 'PrivateIdentifier') {
469
+ return `#${property.name}`;
470
+ }
450
471
  const readsInstance = object?.type === 'ThisExpression' && isThisTheInstance;
451
472
  // An anonymous class expression has an empty name, which no identifier
452
473
  // can match.
@@ -457,12 +478,6 @@ class ASTHelpers {
457
478
  if (!computed && property?.type === 'Identifier') {
458
479
  return property.name;
459
480
  }
460
- // `this.#helper` names a member as precisely as `this.helper` does, and it
461
- // is the only spelling available for an ECMA private member. The `#` is
462
- // part of the name so `#helper` and `helper` stay distinct members.
463
- if (!computed && property?.type === 'PrivateIdentifier') {
464
- return `#${property.name}`;
465
- }
466
481
  // `this['helper']` names the member as precisely as `this.helper` does,
467
482
  // whereas `this[key]` names one only at runtime.
468
483
  if (computed &&
@@ -472,6 +487,25 @@ class ASTHelpers {
472
487
  }
473
488
  return null;
474
489
  }
490
+ /**
491
+ * The ECMA private names a class body declares, spelled as the graph spells
492
+ * them. A private name is scoped to the body that declares it, so this set
493
+ * is what an enclosing class must not mistake for its own members.
494
+ */
495
+ static privateNamesDeclaredBy(node) {
496
+ const names = new Set();
497
+ const members = node.body?.body;
498
+ if (!Array.isArray(members)) {
499
+ return names;
500
+ }
501
+ for (const member of members) {
502
+ const key = member?.key;
503
+ if (key?.type === 'PrivateIdentifier') {
504
+ names.add(`#${key.name}`);
505
+ }
506
+ }
507
+ return names;
508
+ }
475
509
  static walkChildNodes(node, className, isThisTheInstance, dependencies) {
476
510
  for (const [key, value] of Object.entries(node)) {
477
511
  if (ASTHelpers.NON_TRAVERSABLE_NODE_KEYS.has(key)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.154",
3
+ "version": "1.20.156",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,64 @@
1
1
  [
2
+ {
3
+ "version": "1.20.156",
4
+ "date": "2026-08-15T18:39:54.919Z",
5
+ "rules": [
6
+ {
7
+ "name": "logical-top-to-bottom-grouping",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2023
11
+ ],
12
+ "summary": "separate reordered statements so a relocated one cannot land inside a trailing // comment (closes #2023)"
13
+ },
14
+ {
15
+ "name": "prefer-nullish-coalescing-boolean-props",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2024
19
+ ],
20
+ "summary": "carry the comments stranded between the operands instead of deleting them (closes #2024)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.155",
26
+ "date": "2026-08-15T14:21:55.491Z",
27
+ "rules": [
28
+ {
29
+ "name": "class-methods-read-top-to-bottom",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2022
33
+ ],
34
+ "summary": "pin a # field by every read of it, not just this.#x (closes #2022)"
35
+ },
36
+ {
37
+ "name": "no-explicit-return-type",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 2019
41
+ ],
42
+ "summary": "spare the implementation signature of an overload set (closes #2019)"
43
+ },
44
+ {
45
+ "name": "prefer-union-from-const-array",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 2020
49
+ ],
50
+ "summary": "decline in an ambient context, where no const array is legal (closes #2020)"
51
+ },
52
+ {
53
+ "name": "require-image-optimized",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 2021
57
+ ],
58
+ "summary": "exempt the img inside ImageOptimized's own definition (closes #2021)"
59
+ }
60
+ ]
61
+ },
2
62
  {
3
63
  "version": "1.20.154",
4
64
  "date": "2026-08-15T03:47:51.570Z",