@blumintinc/eslint-plugin-blumint 1.20.134 → 1.20.136

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.
@@ -248,6 +248,27 @@ function declaredTypeNode(node) {
248
248
  function checksExcessProperties(typeNode) {
249
249
  return typeNode !== null && !UNCHECKED_ANNOTATION_TYPES.has(typeNode.type);
250
250
  }
251
+ /** `as const` declares no members of its own, so it pins no name. */
252
+ function isConstAssertionType(typeNode) {
253
+ return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
254
+ typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
255
+ typeNode.typeName.name === 'const');
256
+ }
257
+ /**
258
+ * An `as T` / `<T>` whose target type declares members the value must still
259
+ * have. Renaming one of them makes the assertion uncomparable (TS2352), so the
260
+ * name is dictated by `T` rather than chosen by the author — the same reasoning
261
+ * the annotation arms use, applied to the check an assertion actually performs.
262
+ */
263
+ function isCheckedTypeAssertion(node) {
264
+ if (node.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
265
+ node.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) {
266
+ return false;
267
+ }
268
+ const { typeAnnotation } = node;
269
+ return (checksExcessProperties(typeAnnotation) &&
270
+ !isConstAssertionType(typeAnnotation));
271
+ }
251
272
  function isFunctionNode(node) {
252
273
  return (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
253
274
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
@@ -310,10 +331,18 @@ const EXPRESSION_ASSERTION_TYPES = new Set([
310
331
  * members, and climbs THROUGH assertion wrappers, which change no runtime value
311
332
  * and so cannot detach a literal from the declared type it is assigned to
312
333
  * (#1597) — `enforce-object-literal-as-const` ships in the same recommended
313
- * config and appends `as const` to exactly these literals by `--fix`. An
314
- * assertion is transparent, never itself a signal: `{...} as T` still reports,
315
- * because an `as` clause does not reject undeclared members the way an
316
- * annotation does. The walk stops at anything else.
334
+ * config and appends `as const` to exactly these literals by `--fix`.
335
+ *
336
+ * An `as T` is itself a signal when `T` declares members. It is true that an
337
+ * `as` clause does not reject EXCESS members the way an annotation does
338
+ * (`{ orderBy, extra } as Q` compiles; `const c: Q = { orderBy, extra }` is
339
+ * TS2322) — but the operation being gated is a RENAME, which removes a REQUIRED
340
+ * member, and that an assertion does reject: `{ order } as Q` is TS2352 when `Q`
341
+ * requires `orderBy`. Reasoning from excess properties alone made the rule
342
+ * demand a rename that does not compile (#1885). `as const` is the exception
343
+ * and stays transparent, declaring no members of its own.
344
+ *
345
+ * The walk stops at anything else.
317
346
  */
318
347
  function hasConformanceSignal(node) {
319
348
  let current = node;
@@ -330,6 +359,19 @@ function hasConformanceSignal(node) {
330
359
  checksExcessProperties(parent.typeAnnotation)) {
331
360
  return true;
332
361
  }
362
+ // An `as T` DOES pin the member names it requires, even though it does not
363
+ // reject excess ones. The two are different checks: excess-property
364
+ // checking is what an annotation adds, but a RENAME removes a REQUIRED
365
+ // member, and that breaks comparability — `{ beta: 1 } as T` is TS2352 when
366
+ // `T` requires `alpha`. Reasoning from excess properties alone made the rule
367
+ // demand a rename that does not compile (#1885). `as const` stays
368
+ // transparent: it declares no members of its own, and
369
+ // `enforce-object-literal-as-const` appends one to exactly these literals.
370
+ if (isCheckedTypeAssertion(parent) &&
371
+ parent
372
+ .expression === current) {
373
+ return true;
374
+ }
333
375
  if (EXPRESSION_ASSERTION_TYPES.has(parent.type)) {
334
376
  current = parent;
335
377
  continue;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noUselessUsememoPrimitives = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const replacementSegments_1 = require("../utils/replacementSegments");
6
7
  const tsTypeClassifier_1 = require("../utils/tsTypeClassifier");
7
8
  const DEFAULT_OPTIONS = {
8
9
  ignoreCallExpressions: true,
@@ -381,24 +382,44 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
381
382
  valueKind,
382
383
  },
383
384
  fix(fixer) {
385
+ const expressionText = sourceCode.getText(returnedExpression);
384
386
  // Inlining replaces the entire useMemo(...) call with the returned
385
387
  // expression's text, so any comment inside the call but outside
386
388
  // that expression — an eslint-disable-next-line directive on the
387
389
  // return statement among them — has no representation in the
388
- // replacement and would be silently destroyed, changing which
389
- // rules report on the file (#1591). The inlined expression lands
390
- // mid-line (e.g. `const label = <expr>;`), where a -next-line
391
- // directive cannot be hosted, so the autofix declines and leaves
392
- // the report for a manual fix.
390
+ // replacement. Dropping one changes which rules report on the file
391
+ // (#1591), and declining the fix whenever one is present makes a
392
+ // comment decide whether the rule rewrites at all (#1877). Both are
393
+ // avoided by carrying every such comment into the replacement,
394
+ // where the directives among them keep the line relationship they
395
+ // were written with.
393
396
  const strandedComments = sourceCode
394
397
  .getCommentsInside(node)
395
398
  .filter((comment) => comment.range[0] < returnedExpression.range[0] ||
396
399
  comment.range[1] > returnedExpression.range[1]);
397
- if (strandedComments.length > 0) {
398
- return null;
400
+ if (strandedComments.length === 0) {
401
+ return fixer.replaceText(node, `(${expressionText})`);
399
402
  }
400
- const replacement = `(${sourceCode.getText(returnedExpression)})`;
401
- return fixer.replaceText(node, replacement);
403
+ // A comment inside the call lies wholly on one side of the
404
+ // expression, since a comment is a token and cannot straddle a
405
+ // node; keeping each on its own side preserves what it annotates.
406
+ const toSegment = (comment) => ({
407
+ text: sourceCode.text.slice(comment.range[0], comment.range[1]),
408
+ breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
409
+ });
410
+ const isBefore = (comment) => comment.range[0] < returnedExpression.range[0];
411
+ const segments = [
412
+ ...strandedComments.filter(isBefore).map(toSegment),
413
+ { text: expressionText, breakAfter: false },
414
+ ...strandedComments
415
+ .filter((comment) => !isBefore(comment))
416
+ .map(toSegment),
417
+ ];
418
+ // The call can start mid-line, so the indentation of the line it
419
+ // opens on is the only anchor the carried comments have.
420
+ const startLine = sourceCode.lines[node.loc.start.line - 1] ?? '';
421
+ const indent = /^[\t ]*/.exec(startLine)?.[0] ?? '';
422
+ return fixer.replaceText(node, (0, replacementSegments_1.joinSegments)(segments, indent));
402
423
  },
403
424
  });
404
425
  },
@@ -7,6 +7,7 @@ exports.noUsememoForPassByValue = void 0;
7
7
  const utils_1 = require("@typescript-eslint/utils");
8
8
  const typescript_1 = __importDefault(require("typescript"));
9
9
  const createRule_1 = require("../utils/createRule");
10
+ const replacementSegments_1 = require("../utils/replacementSegments");
10
11
  const DEFAULT_EXPENSIVE_PATTERNS = [
11
12
  'compute',
12
13
  'calculate',
@@ -280,37 +281,109 @@ function removeCompleteImport(importDeclaration, sourceCode, fixer) {
280
281
  }
281
282
  return fixer.removeRange([start, removalEnd]);
282
283
  }
283
- function removePartialImport(importDeclaration, remainingSpecifiers, sourceCode, fixer) {
284
- const defaultSpecifier = remainingSpecifiers.find((candidate) => candidate.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
285
- const namespaceSpecifier = remainingSpecifiers.find((candidate) => candidate.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
286
- const namedSpecifiers = remainingSpecifiers.filter((candidate) => candidate.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
287
- const pieces = [];
288
- if (defaultSpecifier) {
289
- pieces.push(sourceCode.getText(defaultSpecifier));
290
- }
291
- if (namespaceSpecifier) {
292
- pieces.push(sourceCode.getText(namespaceSpecifier));
293
- }
294
- if (namedSpecifiers.length > 0) {
295
- pieces.push(`{ ${namedSpecifiers
296
- .map((candidate) => sourceCode.getText(candidate))
297
- .join(', ')} }`);
298
- }
299
- const importPrefix = importDeclaration.importKind === 'type' ? 'import type ' : 'import ';
300
- const newImport = `${importPrefix}${pieces.join(', ')} from ${sourceCode.getText(importDeclaration.source)};`;
301
- return fixer.replaceText(importDeclaration, newImport);
284
+ /**
285
+ * Removing the last binding removes the whole declaration, but a comment
286
+ * written inside it (between the braces, around the source) must not go with
287
+ * it: the declaration's span is re-emitted as just those comments, each kept in
288
+ * order, with a line break appended when the last one would otherwise swallow
289
+ * whatever follows the declaration on its line.
290
+ */
291
+ function removeCompleteImportPreservingComments(importDeclaration, sourceCode, fixer) {
292
+ const comments = sourceCode.getCommentsInside(importDeclaration);
293
+ if (comments.length === 0) {
294
+ return removeCompleteImport(importDeclaration, sourceCode, fixer);
295
+ }
296
+ const text = sourceCode.getText();
297
+ const carried = comments
298
+ .map((comment) => text.slice(comment.range[0], comment.range[1]))
299
+ .join('\n');
300
+ const suffix = (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
301
+ ? '\n'
302
+ : '';
303
+ return fixer.replaceText(importDeclaration, `${carried}${suffix}`);
304
+ }
305
+ function isCommaToken(token) {
306
+ return (token !== null &&
307
+ token.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
308
+ token.value === ',');
302
309
  }
303
- function getImportRemovalFix(specifier, sourceCode, fixer) {
310
+ /**
311
+ * Deletes the `useMemo` specifier and one adjacent comma while other NAMED
312
+ * specifiers remain. Only the tokens being retired are edited — never the
313
+ * declaration re-emitted from its parts — so every comment elsewhere in the
314
+ * declaration survives verbatim. When nothing but whitespace separates the
315
+ * retired tokens from their neighbour the removal range swallows that
316
+ * whitespace too; when a comment sits in between, the tokens are removed
317
+ * individually around it.
318
+ */
319
+ function removeSpecifierFixes(specifier, sourceCode, fixer) {
320
+ const commaAfter = sourceCode.getTokenAfter(specifier);
321
+ if (isCommaToken(commaAfter)) {
322
+ const nextToken = sourceCode.getTokenAfter(commaAfter);
323
+ if (nextToken && !sourceCode.commentsExistBetween(specifier, nextToken)) {
324
+ return [fixer.removeRange([specifier.range[0], nextToken.range[0]])];
325
+ }
326
+ return [fixer.remove(specifier), fixer.removeRange(commaAfter.range)];
327
+ }
328
+ const commaBefore = sourceCode.getTokenBefore(specifier);
329
+ if (isCommaToken(commaBefore)) {
330
+ if (!sourceCode.commentsExistBetween(commaBefore, specifier)) {
331
+ return [fixer.removeRange([commaBefore.range[0], specifier.range[1]])];
332
+ }
333
+ return [fixer.removeRange(commaBefore.range), fixer.remove(specifier)];
334
+ }
335
+ return [fixer.remove(specifier)];
336
+ }
337
+ /**
338
+ * Deletes the whole `{ useMemo }` group — braces, specifier and the comma
339
+ * joining it to the default specifier — when `useMemo` is the only named
340
+ * binding but a default import keeps the declaration alive. As above, comments
341
+ * inside the group survive by shrinking the edit to the individual tokens.
342
+ */
343
+ function removeNamedGroupFixes(specifier, sourceCode, fixer) {
344
+ const openBrace = sourceCode.getTokenBefore(specifier, {
345
+ filter: (token) => token.value === '{',
346
+ });
347
+ const closeBrace = sourceCode.getTokenAfter(specifier, {
348
+ filter: (token) => token.value === '}',
349
+ });
350
+ if (!openBrace || !closeBrace) {
351
+ return [fixer.remove(specifier)];
352
+ }
353
+ const commaBefore = sourceCode.getTokenBefore(openBrace);
354
+ const start = isCommaToken(commaBefore) ? commaBefore : openBrace;
355
+ if (!sourceCode.commentsExistBetween(start, closeBrace)) {
356
+ return [fixer.removeRange([start.range[0], closeBrace.range[1]])];
357
+ }
358
+ const fixes = [];
359
+ if (start !== openBrace) {
360
+ fixes.push(fixer.removeRange(start.range));
361
+ }
362
+ fixes.push(fixer.removeRange(openBrace.range), fixer.remove(specifier));
363
+ const trailingComma = sourceCode.getTokenAfter(specifier);
364
+ if (isCommaToken(trailingComma)) {
365
+ fixes.push(fixer.removeRange(trailingComma.range));
366
+ }
367
+ fixes.push(fixer.removeRange(closeBrace.range));
368
+ return fixes;
369
+ }
370
+ function getImportRemovalFixes(specifier, sourceCode, fixer) {
304
371
  const importDeclaration = specifier.parent;
305
372
  if (!importDeclaration ||
306
373
  importDeclaration.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
307
- return null;
374
+ return [];
308
375
  }
309
376
  const remainingSpecifiers = importDeclaration.specifiers.filter((candidate) => candidate !== specifier);
310
377
  if (remainingSpecifiers.length === 0) {
311
- return removeCompleteImport(importDeclaration, sourceCode, fixer);
378
+ return [
379
+ removeCompleteImportPreservingComments(importDeclaration, sourceCode, fixer),
380
+ ];
381
+ }
382
+ const hasOtherNamedSpecifiers = remainingSpecifiers.some((candidate) => candidate.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
383
+ if (!hasOtherNamedSpecifiers) {
384
+ return removeNamedGroupFixes(specifier, sourceCode, fixer);
312
385
  }
313
- return removePartialImport(importDeclaration, remainingSpecifiers, sourceCode, fixer);
386
+ return removeSpecifierFixes(specifier, sourceCode, fixer);
314
387
  }
315
388
  function getReplacementText(callback, sourceCode) {
316
389
  const returnedExpression = getReturnedExpression(callback);
@@ -323,6 +396,23 @@ function getReplacementText(callback, sourceCode) {
323
396
  }
324
397
  return sourceCode.getText(returnedExpression);
325
398
  }
399
+ /**
400
+ * Comments inside the `useMemo(...)` span but outside the returned expression.
401
+ *
402
+ * The fix replaces the whole call with the text of the returned expression, so
403
+ * the callback wrapper, the `return` keyword, the dependency array and every
404
+ * piece of trivia between them disappear. A comment sitting in any of those
405
+ * places has no surviving anchor in the replacement, so the fixer carries it
406
+ * into the replacement instead (#1877). A comment inside the returned
407
+ * expression needs no carrying: its text is copied verbatim with the
408
+ * expression.
409
+ */
410
+ function strandedCommentsOf(node, returnedExpression, sourceCode) {
411
+ return sourceCode
412
+ .getCommentsInside(node)
413
+ .filter((comment) => comment.range[0] < returnedExpression.range[0] ||
414
+ comment.range[1] > returnedExpression.range[1]);
415
+ }
326
416
  function isSafeAtomicExpression(expression) {
327
417
  switch (expression.type) {
328
418
  case utils_1.AST_NODE_TYPES.Identifier:
@@ -555,16 +645,80 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
555
645
  return null;
556
646
  }
557
647
  const needsParentheses = shouldParenthesizeReplacement(node, returnedExpression, sourceCode);
558
- const replacement = needsParentheses
559
- ? `(${replacementText})`
560
- : replacementText;
648
+ const strandedComments = strandedCommentsOf(node, returnedExpression, sourceCode);
649
+ const fixes = [];
650
+ if (strandedComments.length === 0) {
651
+ fixes.push(fixer.replaceText(node, needsParentheses ? `(${replacementText})` : replacementText));
652
+ }
653
+ else {
654
+ // Carrying must not change the non-comment token stream: whether the
655
+ // replacement is parenthesized is decided by the expression's context
656
+ // exactly as in the comment-free fix, never by the comments themselves.
657
+ // The call can start mid-line, so the indentation of the line it opens
658
+ // on is the only anchor the carried comments have.
659
+ const startLine = sourceCode.lines[node.loc.start.line - 1] ?? '';
660
+ const indent = /^[\t ]*/.exec(startLine)?.[0] ?? '';
661
+ const text = sourceCode.getText();
662
+ const toSegment = (comment) => ({
663
+ text: text.slice(comment.range[0], comment.range[1]),
664
+ breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
665
+ });
666
+ // A stranded comment lies wholly on one side of the expression, since
667
+ // a comment is a token and cannot straddle a node; keeping each on its
668
+ // own side preserves what it annotates.
669
+ const isBefore = (comment) => comment.range[0] < returnedExpression.range[0];
670
+ const leadingComments = strandedComments.filter(isBefore);
671
+ const trailingComments = strandedComments.filter((comment) => !isBefore(comment));
672
+ if (needsParentheses) {
673
+ // Inside parentheses a newline can never trigger ASI, so every
674
+ // comment — line comments and -next-line directives included — can
675
+ // ride within the replacement on a line of its own.
676
+ const segments = [
677
+ ...leadingComments.map(toSegment),
678
+ { text: replacementText, breakAfter: false },
679
+ ...trailingComments.map(toSegment),
680
+ ];
681
+ fixes.push(fixer.replaceText(node, (0, replacementSegments_1.joinSegments)(segments, indent)));
682
+ }
683
+ else {
684
+ // Without parentheses a line break between a restricted keyword
685
+ // (`return`, `throw`, `yield`) and the expression would change the
686
+ // program through ASI, so a leading comment that demands its own
687
+ // line is hoisted onto a full line of its own ABOVE the line the
688
+ // call starts on. That insertion can never split a token pair, and
689
+ // it lands a `-next-line` directive exactly one line above the
690
+ // statement that now hosts its subject. Everything else stays
691
+ // inline: a block comment beside the expression, and a trailing
692
+ // line-bound comment followed by a line break, which is safe after
693
+ // the expression has begun.
694
+ const hoistedComments = leadingComments.filter(replacementSegments_1.requiresLineBreakAfter);
695
+ if (hoistedComments.length > 0) {
696
+ const lineStartIndex = sourceCode.getIndexFromLoc({
697
+ line: node.loc.start.line,
698
+ column: 0,
699
+ });
700
+ const hoisted = hoistedComments
701
+ .map((comment) => `${indent}${text.slice(comment.range[0], comment.range[1])}\n`)
702
+ .join('');
703
+ fixes.push(fixer.insertTextBeforeRange([lineStartIndex, lineStartIndex], hoisted));
704
+ }
705
+ const segments = [
706
+ ...leadingComments
707
+ .filter((comment) => !(0, replacementSegments_1.requiresLineBreakAfter)(comment))
708
+ .map(toSegment),
709
+ { text: replacementText, breakAfter: false },
710
+ ...trailingComments.map(toSegment),
711
+ ];
712
+ const body = (0, replacementSegments_1.joinSegmentBody)(segments, indent);
713
+ const trailing = segments[segments.length - 1].breakAfter
714
+ ? `\n${indent}`
715
+ : '';
716
+ fixes.push(fixer.replaceText(node, `${body}${trailing}`));
717
+ }
718
+ }
561
719
  const specifier = getRemovableImportSpecifier(node, imports, sourceCode);
562
- const fixes = [fixer.replaceText(node, replacement)];
563
720
  if (specifier) {
564
- const removal = getImportRemovalFix(specifier, sourceCode, fixer);
565
- if (removal) {
566
- fixes.push(removal);
567
- }
721
+ fixes.push(...getImportRemovalFixes(specifier, sourceCode, fixer));
568
722
  }
569
723
  return fixes;
570
724
  }
@@ -4,6 +4,7 @@ exports.parallelizeAsyncOperations = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const compilePatternOption_1 = require("../utils/compilePatternOption");
7
8
  // Anchored at the end of the path so multi-part suffixes such as
8
9
  // `EventRegistry.integration.test.ts` are recognized while production modules
9
10
  // that merely contain the word (`testHelpers.ts`, `latest.ts`, `contest/Thing.ts`)
@@ -91,7 +92,15 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
91
92
  return {};
92
93
  }
93
94
  const sourceCode = context.sourceCode;
94
- const sideEffectMatchers = (options?.sideEffectPatterns ?? []).map((pattern) => typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern);
95
+ // Compiling with a bare `new RegExp` throws while the rule is being built,
96
+ // which aborts the whole lint run — every file, every other rule — with a
97
+ // message naming neither this option nor the offending pattern. The schema
98
+ // admits any string, so `'**'` reaches here from a valid config.
99
+ const declaredPatterns = options?.sideEffectPatterns ?? [];
100
+ const sideEffectMatchers = [
101
+ ...(0, compilePatternOption_1.compilePatternOption)('parallelize-async-operations', 'sideEffectPatterns', declaredPatterns.filter((pattern) => typeof pattern === 'string'), 'i'),
102
+ ...declaredPatterns.filter((pattern) => typeof pattern !== 'string'),
103
+ ];
95
104
  const reportedRanges = new Set();
96
105
  /**
97
106
  * Checks if a node is an await expression
@@ -377,6 +377,26 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
377
377
  return 'ok';
378
378
  }
379
379
  // ---- AST helpers --------------------------------------------------------
380
+ /**
381
+ * The expression an optional chain wraps: ESTree hangs a `ChainExpression`
382
+ * above the OUTERMOST link of `a?.b`, so a shape test written against
383
+ * `MemberExpression` sees a different node for a spelling that asks the same
384
+ * question here. `?.` changes nothing this rule decides on shape — the
385
+ * generated lookup copies the discriminant's source text verbatim
386
+ * (`RESULT_BY_KIND[a?.b]`), so the optional link survives the fix intact, and
387
+ * whether the chain can produce `undefined` is answered by the discriminant's
388
+ * TYPE (`hasNullish`), never by its shape. Reading the raw node instead made
389
+ * the narrowing carve-out unreachable and turned a `?.` on a discriminated
390
+ * union into a false positive whose remedy does not compile (#1867).
391
+ *
392
+ * Only one unwrap is needed: inner links of the same chain are plain
393
+ * `MemberExpression`s carrying `optional`, not nested `ChainExpression`s.
394
+ */
395
+ function unwrapChain(node) {
396
+ return node.type === utils_1.AST_NODE_TYPES.ChainExpression
397
+ ? node.expression
398
+ : node;
399
+ }
380
400
  /** Whether a discriminant expression is an identifier or a call-free,
381
401
  * non-optional member chain (safe to collapse repeated evaluations). */
382
402
  function isValidDiscriminant(node) {
@@ -576,14 +596,15 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
576
596
  .toUpperCase();
577
597
  }
578
598
  function deriveLookupName(discriminant) {
599
+ const target = unwrapChain(discriminant);
579
600
  let key = null;
580
- if (discriminant.type === utils_1.AST_NODE_TYPES.Identifier) {
581
- key = discriminant.name;
601
+ if (target.type === utils_1.AST_NODE_TYPES.Identifier) {
602
+ key = target.name;
582
603
  }
583
- else if (discriminant.type === utils_1.AST_NODE_TYPES.MemberExpression &&
584
- !discriminant.computed &&
585
- discriminant.property.type === utils_1.AST_NODE_TYPES.Identifier) {
586
- key = discriminant.property.name;
604
+ else if (target.type === utils_1.AST_NODE_TYPES.MemberExpression &&
605
+ !target.computed &&
606
+ target.property.type === utils_1.AST_NODE_TYPES.Identifier) {
607
+ key = target.property.name;
587
608
  }
588
609
  if (!key) {
589
610
  return null;
@@ -894,12 +915,19 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
894
915
  * would lose (hoisting `this.obj.data` out of the switch drops the narrowing
895
916
  * and the emitted code no longer typechecks), so a `this`-rooted chain is
896
917
  * matched against `this` reads in the kept branches.
918
+ *
919
+ * `obj?.tag` narrows identically too — TypeScript discriminates a union
920
+ * through an optional chain — so the discriminant is unwrapped before its
921
+ * shape is read. A nullable discriminant is exactly where `?.` gets written,
922
+ * and reading the `ChainExpression` as "not a member access" skipped this
923
+ * carve-out entirely (#1867).
897
924
  */
898
925
  function isNarrowingExempt(discriminant, keptValues) {
899
- if (discriminant.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
926
+ const chain = unwrapChain(discriminant);
927
+ if (chain.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
900
928
  return false;
901
929
  }
902
- const root = chainRootName(discriminant);
930
+ const root = chainRootName(chain);
903
931
  if (!root) {
904
932
  return false;
905
933
  }