@blumintinc/eslint-plugin-blumint 1.21.2 → 1.21.4

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.
@@ -35,6 +35,12 @@ const DEFAULT_OPTIONS = {
35
35
  allowModuleScopeFactories: true,
36
36
  };
37
37
  const INLINE_COMPONENT_NAME = 'inline component';
38
+ /**
39
+ * Names followed through wrapper calls before declining. The shapes a fixer
40
+ * emits need two (`const X = memo(XUnmemoized)`, plus one wrapper above it);
41
+ * the bound is what keeps a cycle or a long chain from walking the scope graph.
42
+ */
43
+ const MAX_ALIAS_HOPS = 3;
38
44
  function isPascalCase(name) {
39
45
  return /^[A-Z][A-Za-z0-9]*$/.test(name);
40
46
  }
@@ -144,25 +150,42 @@ function getCalleeName(callee) {
144
150
  }
145
151
  return null;
146
152
  }
147
- function getFunctionFromCall(call) {
153
+ const WRAPPER_CALLEE_NAMES = new Set([
154
+ 'useCallback',
155
+ 'React.useCallback',
156
+ 'useMemo',
157
+ 'React.useMemo',
158
+ 'memo',
159
+ 'React.memo',
160
+ 'forwardRef',
161
+ 'React.forwardRef',
162
+ ]);
163
+ /**
164
+ * `resolveIdentifier` is what lets `memo(Inner)` be judged at all. `require-memo`
165
+ * rewrites a nested component into `function InnerUnmemoized() {...}` plus
166
+ * `const Inner = memo(InnerUnmemoized)`, so reading only an inline literal here
167
+ * made that rewrite silence this rule while the hazard survived: the renamed
168
+ * declaration is still recreated per render, and `memo` re-invoked on a fresh
169
+ * argument yields a fresh component type. Callers that cannot decide where the
170
+ * name is declared pass no resolver and keep the literal-only reading.
171
+ */
172
+ function getFunctionFromCall(call, resolveIdentifier) {
148
173
  const calleeName = getCalleeName(call.callee);
149
- const firstArg = unwrapExpression(call.arguments[0] ?? null);
150
- if (!firstArg || !isFunctionNode(firstArg)) {
174
+ if (!calleeName || !WRAPPER_CALLEE_NAMES.has(calleeName)) {
151
175
  return undefined;
152
176
  }
153
- if (calleeName === 'useCallback' ||
154
- calleeName === 'React.useCallback' ||
155
- calleeName === 'useMemo' ||
156
- calleeName === 'React.useMemo' ||
157
- calleeName === 'memo' ||
158
- calleeName === 'React.memo' ||
159
- calleeName === 'forwardRef' ||
160
- calleeName === 'React.forwardRef') {
177
+ const firstArg = unwrapExpression(call.arguments[0] ?? null);
178
+ if (!firstArg)
179
+ return undefined;
180
+ if (isFunctionNode(firstArg)) {
161
181
  return firstArg;
162
182
  }
183
+ if (firstArg.type === utils_1.AST_NODE_TYPES.Identifier && resolveIdentifier) {
184
+ return resolveIdentifier(firstArg);
185
+ }
163
186
  return undefined;
164
187
  }
165
- function getFunctionFromInit(init) {
188
+ function getFunctionFromInit(init, resolveIdentifier) {
166
189
  const unwrapped = unwrapExpression(init);
167
190
  if (!unwrapped)
168
191
  return undefined;
@@ -170,7 +193,7 @@ function getFunctionFromInit(init) {
170
193
  return unwrapped;
171
194
  }
172
195
  if (unwrapped.type === utils_1.AST_NODE_TYPES.CallExpression) {
173
- return getFunctionFromCall(unwrapped);
196
+ return getFunctionFromCall(unwrapped, resolveIdentifier);
174
197
  }
175
198
  return undefined;
176
199
  }
@@ -348,6 +371,52 @@ exports.noInlineComponentProp = (0, createRule_1.createRule)({
348
371
  }
349
372
  return false;
350
373
  }
374
+ /**
375
+ * Follows an identifier in a wrapper call's argument position to the
376
+ * function it names, declining wherever the reference does not prove
377
+ * per-render churn.
378
+ *
379
+ * Every decline below is a name whose binding is NOT recreated by the
380
+ * consuming render, or one this rule cannot see the definition of at all:
381
+ * an unresolved name (import, global, ambient), a parameter, a catch or
382
+ * class binding, a name declared more than once, a binding assigned more
383
+ * than once (its identity is not fixed by any single declaration), and a
384
+ * declaration outside the consuming scope. Only a single-write `Variable`
385
+ * or `FunctionName` declared by the very function whose JSX passes it along
386
+ * churns per render.
387
+ */
388
+ function resolveLocalFunction(identifier, consumerFunction, seen) {
389
+ // An alias chain is bounded so a cycle (`const A = memo(A)`) and a long
390
+ // re-export chain both terminate rather than recursing on the scope graph.
391
+ if (seen.size >= MAX_ALIAS_HOPS)
392
+ return undefined;
393
+ const variable = findVariableInScopes(context, identifier);
394
+ if (!variable || seen.has(variable))
395
+ return undefined;
396
+ seen.add(variable);
397
+ if (variable.defs.length !== 1)
398
+ return undefined;
399
+ const definition = variable.defs[0];
400
+ if (definition.type !== 'Variable' &&
401
+ definition.type !== 'FunctionName') {
402
+ return undefined;
403
+ }
404
+ const writeCount = variable.references.filter((reference) => reference.isWrite()).length;
405
+ if (writeCount > 1)
406
+ return undefined;
407
+ const defNode = definition.node;
408
+ if (resolvedOptions.allowModuleScopeFactories &&
409
+ isStableForConsumer(defNode, consumerFunction)) {
410
+ return undefined;
411
+ }
412
+ if (defNode.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
413
+ return defNode;
414
+ }
415
+ if (defNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
416
+ return getFunctionFromInit(defNode.init, (next) => resolveLocalFunction(next, consumerFunction, seen));
417
+ }
418
+ return undefined;
419
+ }
351
420
  function shouldReportDefinition(definition, displayName, consumerFunction) {
352
421
  if (definition.type === 'ImportBinding' ||
353
422
  definition.type === 'Parameter' ||
@@ -364,7 +433,7 @@ exports.noInlineComponentProp = (0, createRule_1.createRule)({
364
433
  fnNode = defNode;
365
434
  }
366
435
  else if (defNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
367
- fnNode = getFunctionFromInit(defNode.init);
436
+ fnNode = getFunctionFromInit(defNode.init, (identifier) => resolveLocalFunction(identifier, consumerFunction, new Set()));
368
437
  }
369
438
  else {
370
439
  return false;
@@ -3,6 +3,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noRestrictedPropertiesFix = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const utils_1 = require("@typescript-eslint/utils");
6
+ /**
7
+ * Mirrors `global-const-style`'s own `toUpperSnakeCase`: both must agree on
8
+ * what a camelCase name becomes so that a config `object` string written
9
+ * before the sibling rule's rename still recognizes the code after it
10
+ * (Issue #2318 -- `global-const-style` renames a module-scope
11
+ * `disallowedObject` const to `DISALLOWED_OBJECT`, and a purely spelling-based
12
+ * match against `disallowedObject` goes silent on the renamed identifier even
13
+ * though the same restricted property is still being read off it). Splitting
14
+ * on case *boundaries* rather than just uppercasing keeps acronym runs intact
15
+ * and reproduces the sibling rule's output exactly.
16
+ */
17
+ function toUpperSnakeCase(name) {
18
+ return name
19
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
20
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
21
+ .toUpperCase()
22
+ .replace(/^_/, '');
23
+ }
24
+ /** Whether `name` already has the exact shape `global-const-style` emits. */
25
+ function isUpperSnakeCase(name) {
26
+ return /^[A-Z][A-Z0-9_]*$/.test(name);
27
+ }
28
+ /**
29
+ * Matches a configured `object` name against an identifier, tolerating the
30
+ * one rewrite `global-const-style` performs on it: a module-scope const
31
+ * renamed from camelCase to UPPER_SNAKE_CASE. The match stays restricted to
32
+ * identifiers that are ALREADY in that exact UPPER_SNAKE_CASE shape --
33
+ * comparing case-insensitively across the board would also equate a
34
+ * configured `foo` with an unrelated PASCAL-cased `Foo` (a React component
35
+ * name, say), which `global-const-style` never rewrites and which the
36
+ * configured `foo` was never meant to reach. Restricting the tolerant branch
37
+ * to names `global-const-style` could plausibly have produced keeps the
38
+ * broadened match tied to the one rename it is compensating for, rather than
39
+ * a blanket case-insensitive comparison that would invite false positives on
40
+ * unrelated identifiers.
41
+ */
42
+ function objectNameMatches(identifierName, configuredName) {
43
+ return (identifierName === configuredName ||
44
+ (isUpperSnakeCase(identifierName) &&
45
+ identifierName === toUpperSnakeCase(configuredName)));
46
+ }
6
47
  /**
7
48
  * This rule is a wrapper around the core ESLint no-restricted-properties rule
8
49
  * that adds special handling for Object.keys() and Object.values() results.
@@ -98,7 +139,7 @@ exports.noRestrictedPropertiesFix = (0, createRule_1.createRule)({
98
139
  for (const restrictedProp of restrictedProperties) {
99
140
  const objectMatches = restrictedProp.object &&
100
141
  node.object.type === utils_1.AST_NODE_TYPES.Identifier &&
101
- node.object.name === restrictedProp.object;
142
+ objectNameMatches(node.object.name, restrictedProp.object);
102
143
  const propertyMatches = restrictedProp.property &&
103
144
  ((node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
104
145
  node.property.name === restrictedProp.property) ||
@@ -124,9 +165,18 @@ exports.noRestrictedPropertiesFix = (0, createRule_1.createRule)({
124
165
  restrictedProp.property &&
125
166
  propertyMatches) {
126
167
  // Check if the object is in the allowObjects list
127
- if (restrictedProp.allowObjects &&
168
+ const objectIdentifierName = node.object.type === utils_1.AST_NODE_TYPES.Identifier
169
+ ? node.object.name
170
+ : '';
171
+ // `allowObjects` names a BINDING exactly as `object` does, so it
172
+ // must tolerate the same UPPER_SNAKE_CASE rewrite. Normalizing only
173
+ // the restrictive side would let `global-const-style`'s rename turn
174
+ // an explicitly allowed access (`router.push`) into a reported one
175
+ // (#2318).
176
+ const allowObjects = restrictedProp.allowObjects;
177
+ if (allowObjects &&
128
178
  node.object.type === utils_1.AST_NODE_TYPES.Identifier &&
129
- restrictedProp.allowObjects.includes(node.object.name)) {
179
+ allowObjects.some((allowed) => objectNameMatches(objectIdentifierName, allowed))) {
130
180
  continue;
131
181
  }
132
182
  const objectName = node.object.type === utils_1.AST_NODE_TYPES.Identifier
@@ -80,16 +80,44 @@ function isStringLikeWithoutTypes(expr) {
80
80
  function rangesOverlap(a, b) {
81
81
  return a[0] < b[1] && b[0] < a[1];
82
82
  }
83
- function isUseMemoCallee(callee) {
83
+ /**
84
+ * The memo hooks this rule governs, by the name they are called under.
85
+ *
86
+ * `useDeepCompareMemo` (from `@blumintinc/use-deep-compare`) mirrors `useMemo`
87
+ * argument for argument — a callback whose result is cached against a
88
+ * dependency array — so every judgement below reads the same call shape and
89
+ * every rewrite produces the same text. Memoizing a primitive is equally
90
+ * pointless under it: a value with no identity to preserve gains nothing from a
91
+ * cache, and deep comparison makes the bargain WORSE rather than acceptable,
92
+ * since each render now walks the dependency array by value to protect it.
93
+ *
94
+ * They are enumerated rather than matched by prefix because a fixable,
95
+ * recommended sibling rewrites the callee of a `useMemo` to the deep-compare
96
+ * spelling to repair a dependency-identity problem, leaving the callback
97
+ * byte-identical. Keyed on the literal `useMemo` alone, this rule went silent on
98
+ * the result while the useless memoization it objects to survived the rename
99
+ * untouched — renaming the callee by hand reproduces the blind spot with no
100
+ * fixer involved (#2312).
101
+ */
102
+ const MEMO_HOOK_NAMES = new Set(['useMemo', 'useDeepCompareMemo']);
103
+ /**
104
+ * The hook a call invokes, or `null` where it is not a memo hook at all.
105
+ *
106
+ * A namespaced call answers with the bare hook name rather than the whole
107
+ * callee text, so `React.useMemo` and `useMemo` report identically: the
108
+ * namespace is the caller's import style, not part of what the report is about.
109
+ */
110
+ function memoHookNameOf(callee) {
84
111
  if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
85
- return callee.name === 'useMemo';
112
+ return MEMO_HOOK_NAMES.has(callee.name) ? callee.name : null;
86
113
  }
87
114
  if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
88
115
  !callee.computed &&
89
116
  callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
90
- return callee.property.name === 'useMemo';
117
+ const { name } = callee.property;
118
+ return MEMO_HOOK_NAMES.has(name) ? name : null;
91
119
  }
92
- return false;
120
+ return null;
93
121
  }
94
122
  function getReturnedExpression(callback) {
95
123
  if (callback.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
@@ -320,7 +348,7 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
320
348
  },
321
349
  ],
322
350
  messages: {
323
- uselessUseMemoPrimitive: 'useMemo wraps a primitive {{valueKind}}. → Primitives are pass-by-value and have no identity to preserve, so memoization provides zero referential-stability benefit and only adds unnecessary hook overhead. → Remove useMemo and inline the expression directly.',
351
+ uselessUseMemoPrimitive: '{{hook}} wraps a primitive {{valueKind}}. → Primitives are pass-by-value and have no identity to preserve, so memoization provides zero referential-stability benefit and only adds unnecessary hook overhead. → Remove {{hook}} and inline the expression directly.',
324
352
  },
325
353
  },
326
354
  defaultOptions: [DEFAULT_OPTIONS],
@@ -361,9 +389,9 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
361
389
  });
362
390
  }
363
391
  /**
364
- * Every `useMemo` call the rule reports, in traversal order.
392
+ * Every memo-hook call the rule reports, in traversal order.
365
393
  *
366
- * Reporting is deferred to `Program:exit` because the `useMemo` import is
394
+ * Reporting is deferred to `Program:exit` because the hook's import is
367
395
  * unbound only once no surviving call references it. Judged one call at a
368
396
  * time, a file with two of them never sees either as the binding's last
369
397
  * use, and the pass that unwraps both resolves every report — so nothing
@@ -630,7 +658,8 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
630
658
  }
631
659
  return {
632
660
  CallExpression(node) {
633
- if (!isUseMemoCallee(node.callee)) {
661
+ const hook = memoHookNameOf(node.callee);
662
+ if (!hook) {
634
663
  return;
635
664
  }
636
665
  if (node.arguments.length === 0) {
@@ -677,16 +706,18 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
677
706
  if (!isPrimitive) {
678
707
  return;
679
708
  }
680
- violations.push({ node, returnedExpression, valueKind });
709
+ violations.push({ node, returnedExpression, valueKind, hook });
681
710
  },
682
711
  'Program:exit'() {
683
712
  if (violations.length === 0)
684
713
  return;
685
714
  const planned = planViolations();
686
- // One plan over every surviving rewrite: the `useMemo` binding is left
715
+ // One plan over every surviving rewrite: the hook's binding is left
687
716
  // unreferenced by their union even when no single unwrap strips its
688
717
  // last use, and the pass that applies them all resolves every report —
689
- // so this is the only moment the stranded import is visible.
718
+ // so this is the only moment the stranded import is visible. Which
719
+ // binding that is comes from the deletions themselves, so the
720
+ // deep-compare import is unbound on the same terms as React's.
690
721
  const importRemoval = planned.length > 0
691
722
  ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed))
692
723
  : null;
@@ -705,6 +736,7 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
705
736
  node: violation.node,
706
737
  messageId: 'uselessUseMemoPrimitive',
707
738
  data: {
739
+ hook: violation.hook,
708
740
  valueKind: violation.valueKind,
709
741
  },
710
742
  fix: violation === carrier?.violation
@@ -114,16 +114,52 @@ function isStaticClassMember(node, context) {
114
114
  return false;
115
115
  }
116
116
  /**
117
- * Check if the property name matches the variable name in an assignment
117
+ * Extracts the literal name a MemberExpression's property spells, when that
118
+ * name is comparable to a binding identifier (an Identifier's own name, or a
119
+ * string Literal's value). A non-string literal (e.g. `obj[0]`) can never
120
+ * equal an identifier name, so it is excluded here rather than at every
121
+ * caller.
118
122
  */
119
- function isMatchingPropertyName(propertyNode, variableName) {
123
+ function getComparablePropertyName(propertyNode) {
120
124
  if (propertyNode.type === utils_1.AST_NODE_TYPES.Identifier) {
121
- return propertyNode.name === variableName;
125
+ return propertyNode.name;
122
126
  }
123
- if (propertyNode.type === utils_1.AST_NODE_TYPES.Literal) {
124
- return propertyNode.value === variableName;
127
+ if (propertyNode.type === utils_1.AST_NODE_TYPES.Literal &&
128
+ typeof propertyNode.value === 'string') {
129
+ return propertyNode.value;
125
130
  }
126
- return false;
131
+ return null;
132
+ }
133
+ /**
134
+ * Check if the property name matches the variable name in an assignment
135
+ */
136
+ function isMatchingPropertyName(propertyNode, variableName) {
137
+ return getComparablePropertyName(propertyNode) === variableName;
138
+ }
139
+ /**
140
+ * Strips underscores and lowercases a name so a SCREAMING_SNAKE_CASE binding
141
+ * compares equal to the camelCase property it reads (`MY_VALUE` vs
142
+ * `myValue`), not merely a pure case shift (`FOO` vs `foo`). A rename tool
143
+ * such as `global-const-style` reshapes only the BINDING to fit a naming
144
+ * convention; it never touches the property expression on the right-hand
145
+ * side, so the two spellings diverge in casing and word separators without
146
+ * the assignment becoming a genuine rename of what the binding refers to.
147
+ */
148
+ function normalizeForLooseNameMatch(name) {
149
+ return name.replace(/_/g, '').toLowerCase();
150
+ }
151
+ /**
152
+ * Case/underscore-insensitive counterpart to `isMatchingPropertyName`, used
153
+ * only where `enforceForRenamedProperties` is off. The default gate must
154
+ * still recognize `const FOO = obj.foo;` as the same property access it
155
+ * recognizes for `const foo = obj.foo;` (#2316), while a genuinely different
156
+ * name (`TOTAL` vs `count`) keeps failing the match.
157
+ */
158
+ function isMatchingPropertyNameIgnoringCase(propertyNode, variableName) {
159
+ const propertyName = getComparablePropertyName(propertyNode);
160
+ return (propertyName !== null &&
161
+ normalizeForLooseNameMatch(propertyName) ===
162
+ normalizeForLooseNameMatch(variableName));
127
163
  }
128
164
  /**
129
165
  * Get the property text for destructuring
@@ -235,13 +271,20 @@ exports.preferDestructuringNoClass = (0, createRule_1.createRule)({
235
271
  if (isPrivateIdentifierProperty(memberExpression.property)) {
236
272
  return false;
237
273
  }
274
+ // `super.x` has no destructurable form: `const { x } = super;` is a
275
+ // syntax error, since `super` must be followed by a call or a member
276
+ // access. Reachable for any binding whose name matches the property in
277
+ // any casing, so the guard belongs here rather than at one call site.
278
+ if (memberExpression.object.type === utils_1.AST_NODE_TYPES.Super) {
279
+ return false;
280
+ }
238
281
  if (!options.object) {
239
282
  return false;
240
283
  }
241
284
  if (options.enforceForRenamedProperties) {
242
285
  return true;
243
286
  }
244
- return isMatchingPropertyName(memberExpression.property, identifier.name);
287
+ return isMatchingPropertyNameIgnoringCase(memberExpression.property, identifier.name);
245
288
  }
246
289
  function getPatternKeyText(memberExpression, propertyText) {
247
290
  if (memberExpression.computed) {
@@ -254,9 +297,15 @@ exports.preferDestructuringNoClass = (0, createRule_1.createRule)({
254
297
  return null;
255
298
  }
256
299
  const patternKeyText = getPatternKeyText(memberExpression, propertyText);
300
+ // Alias whenever the destructured key would not itself spell the
301
+ // target binding: a computed key never can, and any literal spelling
302
+ // mismatch — an explicit rename under `enforceForRenamedProperties`,
303
+ // or a case/underscore-only difference the default gate now tolerates
304
+ // (#2316) — must keep `key: target`, or the emitted destructuring
305
+ // binds the wrong name (or, for `const { FOO } = OBJ;` where the
306
+ // property is `foo`, no name at all).
257
307
  if (memberExpression.computed ||
258
- (options.enforceForRenamedProperties &&
259
- !isMatchingPropertyName(memberExpression.property, targetName))) {
308
+ !isMatchingPropertyName(memberExpression.property, targetName)) {
260
309
  return `${patternKeyText}: ${targetName}`;
261
310
  }
262
311
  return patternKeyText;
@@ -323,8 +372,13 @@ exports.preferDestructuringNoClass = (0, createRule_1.createRule)({
323
372
  function buildReportDetails(memberExpr, targetName, examplePrefix, exampleSuffix) {
324
373
  const objectText = sourceCode.getText(memberExpr.object);
325
374
  const propertyText = getPropertyText(memberExpr.property, memberExpr.computed, sourceCode);
326
- const usesRenaming = options.enforceForRenamedProperties &&
327
- !!targetName &&
375
+ // Tracks whether the FIXED destructuring needs an alias — the same
376
+ // question `getDestructuringBindingText` answers — so the message
377
+ // wording ("with renaming", the target-name note) stays truthful for
378
+ // a case/underscore-only spelling difference the default gate now
379
+ // reports (#2316), not only for an explicit
380
+ // `enforceForRenamedProperties` rename.
381
+ const usesRenaming = !!targetName &&
328
382
  !isMatchingPropertyName(memberExpr.property, targetName);
329
383
  const aliasName = targetName ?? propertyText;
330
384
  const patternKeyText = getPatternKeyText(memberExpr, propertyText);
@@ -20,6 +20,63 @@ function isObjectExpression(node) {
20
20
  function isProperty(node) {
21
21
  return node.type === utils_1.AST_NODE_TYPES.Property;
22
22
  }
23
+ /**
24
+ * Resolves the property name every static spelling of a key denotes:
25
+ * `shouldFlatten`, `'shouldFlatten'` and `['shouldFlatten']` all occupy the
26
+ * same slot, so a literal that writes one of them cannot gain another without
27
+ * duplicating the key. A key built from an expression denotes an unknown
28
+ * property and yields no name.
29
+ */
30
+ function staticKeyName(property) {
31
+ const key = property.key;
32
+ if (!property.computed && isIdentifier(key)) {
33
+ return key.name;
34
+ }
35
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
36
+ return key.value;
37
+ }
38
+ if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
39
+ key.expressions.length === 0 &&
40
+ key.quasis.length === 1) {
41
+ return key.quasis[0].value.cooked;
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * The single answer to "does this options literal already write shouldFlatten".
47
+ * The reader that decides whether to report and the writer that builds the
48
+ * suggestion share it so they cannot disagree about which members exist: a
49
+ * writer that misses a member the reader sees appends a duplicate key.
50
+ */
51
+ function findShouldFlattenProperty(options) {
52
+ for (const property of options.properties) {
53
+ if (!isProperty(property))
54
+ continue;
55
+ if (staticKeyName(property) === 'shouldFlatten') {
56
+ return property;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+ function isLiteralBoolean(node, expected) {
62
+ return node.type === utils_1.AST_NODE_TYPES.Literal && node.value === expected;
63
+ }
64
+ /**
65
+ * Flattening counts as enabled only for a literal `true`. A variable, a
66
+ * ternary or a call may evaluate either way, so those are treated as unknown
67
+ * and the violation is still reported.
68
+ */
69
+ function hasEnabledShouldFlatten(newExpr) {
70
+ if (newExpr.arguments.length < 2) {
71
+ return false;
72
+ }
73
+ const optionsArg = newExpr.arguments[1];
74
+ if (!isObjectExpression(optionsArg)) {
75
+ return false;
76
+ }
77
+ const property = findShouldFlattenProperty(optionsArg);
78
+ return !!property && isLiteralBoolean(property.value, true);
79
+ }
23
80
  /**
24
81
  * Appends an entry to a comma-separated list by anchoring on its last element
25
82
  * and deriving the separator from whatever already follows that element.
@@ -105,6 +162,18 @@ exports.preferDocumentFlattening = (0, createRule_1.createRule)({
105
162
  if (!isObjectExpression(optionsArg)) {
106
163
  return null;
107
164
  }
165
+ const existing = findShouldFlattenProperty(optionsArg);
166
+ if (existing) {
167
+ // Appending a second `shouldFlatten` member is never correct: the
168
+ // literal would carry the key twice (TS1117, and core no-dupe-keys).
169
+ // A literal `false` is rewritten in place; any other value — a
170
+ // variable, a ternary, a call, a shorthand reference, an accessor —
171
+ // may already be true, so the edit is declined rather than guessed.
172
+ if (isLiteralBoolean(existing.value, false)) {
173
+ return fixer.replaceText(existing.value, 'true');
174
+ }
175
+ return null;
176
+ }
108
177
  const lastEntry = optionsArg.properties[optionsArg.properties.length - 1];
109
178
  if (!lastEntry) {
110
179
  // An empty object offers no entry to anchor on, so the opening brace
@@ -148,25 +217,8 @@ exports.preferDocumentFlattening = (0, createRule_1.createRule)({
148
217
  // Only check DocSetter and DocSetterTransaction classes
149
218
  if (className !== 'DocSetter' && className !== 'DocSetterTransaction')
150
219
  return;
151
- // Check if shouldFlatten option is provided
152
- let hasShouldFlatten = false;
153
220
  // The options object is typically the second argument
154
- if (node.arguments.length >= 2) {
155
- const optionsArg = node.arguments[1];
156
- if (isObjectExpression(optionsArg)) {
157
- for (const property of optionsArg.properties) {
158
- if (!isProperty(property))
159
- continue;
160
- if (isIdentifier(property.key) &&
161
- property.key.name === 'shouldFlatten' &&
162
- property.value.type === utils_1.AST_NODE_TYPES.Literal &&
163
- property.value.value === true) {
164
- hasShouldFlatten = true;
165
- break;
166
- }
167
- }
168
- }
169
- }
221
+ const hasShouldFlatten = hasEnabledShouldFlatten(node);
170
222
  // Get variable name from parent node if it's a variable declaration
171
223
  let instanceName = '';
172
224
  if (node.parent &&
@@ -203,23 +255,7 @@ exports.preferDocumentFlattening = (0, createRule_1.createRule)({
203
255
  const className = object.callee.name;
204
256
  if (className === 'DocSetter' ||
205
257
  className === 'DocSetterTransaction') {
206
- let hasShouldFlatten = false;
207
- if (object.arguments.length >= 2) {
208
- const optionsArg = object.arguments[1];
209
- if (isObjectExpression(optionsArg)) {
210
- for (const property of optionsArg.properties) {
211
- if (!isProperty(property))
212
- continue;
213
- if (isIdentifier(property.key) &&
214
- property.key.name === 'shouldFlatten' &&
215
- property.value.type === utils_1.AST_NODE_TYPES.Literal &&
216
- property.value.value === true) {
217
- hasShouldFlatten = true;
218
- break;
219
- }
220
- }
221
- }
222
- }
258
+ const hasShouldFlatten = hasEnabledShouldFlatten(object);
223
259
  if (!hasShouldFlatten) {
224
260
  instance = {
225
261
  className,