@blumintinc/eslint-plugin-blumint 1.20.133 → 1.20.135

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.133',
226
+ version: '1.20.135',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -346,6 +346,77 @@ function definesNumericBinding(def) {
346
346
  return (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator ||
347
347
  isNumberAnnotated(def.name));
348
348
  }
349
+ /**
350
+ * Utility wrappers whose single type argument keeps a Record annotation's key
351
+ * domain intact: `Readonly<Record<K, V>>` and `Partial<Record<K, V>>` admit
352
+ * exactly the keys `Record<K, V>` admits.
353
+ */
354
+ const RECORD_KEY_PRESERVING_WRAPPERS = new Set(['Readonly', 'Partial']);
355
+ /**
356
+ * The property names assertSafe exists to keep out of a lookup. A literal key
357
+ * union that names one of them proves nothing about safety, so the
358
+ * compiler-bounded carve-out refuses it and the key keeps being reported.
359
+ */
360
+ const PROTOTYPE_SURFACE_NAMES = new Set([
361
+ '__proto__',
362
+ 'constructor',
363
+ 'prototype',
364
+ ]);
365
+ /**
366
+ * Union semantics over domains: one open member opens the whole domain, an
367
+ * unclassifiable member leaves it unclassifiable (it could be hiding an open
368
+ * one), and a closed-but-unenumerable member keeps the union closed without a
369
+ * literal listing.
370
+ */
371
+ function foldKeyDomains(domains) {
372
+ const values = new Set();
373
+ let closed = false;
374
+ let unknown = false;
375
+ for (const domain of domains) {
376
+ if (domain === 'open') {
377
+ return 'open';
378
+ }
379
+ if (domain === 'unknown') {
380
+ unknown = true;
381
+ }
382
+ else if (domain === 'closed') {
383
+ closed = true;
384
+ }
385
+ else {
386
+ for (const value of domain) {
387
+ values.add(value);
388
+ }
389
+ }
390
+ }
391
+ if (unknown) {
392
+ return 'unknown';
393
+ }
394
+ if (closed) {
395
+ return 'closed';
396
+ }
397
+ return values;
398
+ }
399
+ /**
400
+ * An enum is a compiler-checked finite set. Members with literal initializers
401
+ * enumerate their runtime key strings (which is what lets the forbidden-name
402
+ * screen and subset comparison see them); a computed or auto-numbered member
403
+ * leaves the set finite but unenumerable.
404
+ */
405
+ function enumKeyDomain(node) {
406
+ const values = new Set();
407
+ for (const member of node.members) {
408
+ const { initializer } = member;
409
+ if (initializer?.type === utils_1.AST_NODE_TYPES.Literal &&
410
+ (typeof initializer.value === 'string' ||
411
+ typeof initializer.value === 'number')) {
412
+ values.add(String(initializer.value));
413
+ }
414
+ else {
415
+ return 'closed';
416
+ }
417
+ }
418
+ return values;
419
+ }
349
420
  exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
350
421
  name: 'enforce-assert-safe-object-key',
351
422
  meta: {
@@ -620,6 +691,260 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
620
691
  init.callee.name === 'assertSafe');
621
692
  });
622
693
  };
694
+ /**
695
+ * The declared key domain of a type annotation. Alias references resolve
696
+ * through the scope chain to the declaration in this file — a type alias
697
+ * recurses into what it aliases, an enum enumerates its members, a generic
698
+ * type parameter is judged by its constraint (an unconstrained one could be
699
+ * instantiated with anything, so it reads as open). `seen` terminates a
700
+ * recursive alias without conflating it with a sibling reference to the
701
+ * same name.
702
+ */
703
+ const keyDomainOf = (typeNode, anchor, seen) => {
704
+ switch (typeNode.type) {
705
+ case utils_1.AST_NODE_TYPES.TSLiteralType: {
706
+ const { literal } = typeNode;
707
+ if (literal.type === utils_1.AST_NODE_TYPES.Literal &&
708
+ (typeof literal.value === 'string' ||
709
+ typeof literal.value === 'number')) {
710
+ return new Set([String(literal.value)]);
711
+ }
712
+ return 'unknown';
713
+ }
714
+ case utils_1.AST_NODE_TYPES.TSUnionType:
715
+ return foldKeyDomains(typeNode.types.map((member) => keyDomainOf(member, anchor, seen)));
716
+ case utils_1.AST_NODE_TYPES.TSTypeReference: {
717
+ if (typeNode.typeParameters ||
718
+ typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
719
+ return 'unknown';
720
+ }
721
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, anchor), typeNode.typeName.name);
722
+ if (!variable || variable.defs.length === 0 || seen.has(variable)) {
723
+ return 'unknown';
724
+ }
725
+ const nextSeen = new Set(seen).add(variable);
726
+ return foldKeyDomains(variable.defs.map((def) => {
727
+ switch (def.node.type) {
728
+ case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration:
729
+ return keyDomainOf(def.node.typeAnnotation, anchor, nextSeen);
730
+ case utils_1.AST_NODE_TYPES.TSEnumDeclaration:
731
+ return enumKeyDomain(def.node);
732
+ case utils_1.AST_NODE_TYPES.TSTypeParameter:
733
+ return def.node.constraint
734
+ ? keyDomainOf(def.node.constraint, anchor, nextSeen)
735
+ : 'open';
736
+ default:
737
+ return 'unknown';
738
+ }
739
+ }));
740
+ }
741
+ // `(typeof KINDS)[number]` — the union derived from a values array,
742
+ // which prefer-union-from-const-array rewrites literal-union aliases
743
+ // into. The members live in a value rather than the type syntax, so
744
+ // they are read off the array's own literal elements — but only under
745
+ // `as const`: without it the array's type widens to `string[]` and the
746
+ // indexed access IS `string`, the open domain this rule exists to keep
747
+ // reported.
748
+ case utils_1.AST_NODE_TYPES.TSIndexedAccessType: {
749
+ if (typeNode.objectType.type !== utils_1.AST_NODE_TYPES.TSTypeQuery) {
750
+ return 'unknown';
751
+ }
752
+ const { exprName } = typeNode.objectType;
753
+ if (exprName.type !== utils_1.AST_NODE_TYPES.Identifier) {
754
+ return 'unknown';
755
+ }
756
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, anchor), exprName.name);
757
+ if (!variable || variable.defs.length !== 1) {
758
+ return 'unknown';
759
+ }
760
+ const def = variable.defs[0];
761
+ if (def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
762
+ return 'unknown';
763
+ }
764
+ const init = def.node.init;
765
+ const isAsConstArray = init?.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
766
+ init.typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
767
+ init.typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
768
+ init.typeAnnotation.typeName.name === 'const' &&
769
+ init.expression.type === utils_1.AST_NODE_TYPES.ArrayExpression;
770
+ if (!isAsConstArray) {
771
+ return 'unknown';
772
+ }
773
+ if (typeNode.indexType.type !== utils_1.AST_NODE_TYPES.TSNumberKeyword) {
774
+ return 'closed';
775
+ }
776
+ const values = new Set();
777
+ for (const element of init.expression
778
+ .elements) {
779
+ if (element?.type === utils_1.AST_NODE_TYPES.Literal &&
780
+ (typeof element.value === 'string' ||
781
+ typeof element.value === 'number')) {
782
+ values.add(String(element.value));
783
+ }
784
+ else {
785
+ return 'closed';
786
+ }
787
+ }
788
+ return values;
789
+ }
790
+ case utils_1.AST_NODE_TYPES.TSStringKeyword:
791
+ case utils_1.AST_NODE_TYPES.TSNumberKeyword:
792
+ case utils_1.AST_NODE_TYPES.TSSymbolKeyword:
793
+ case utils_1.AST_NODE_TYPES.TSAnyKeyword:
794
+ case utils_1.AST_NODE_TYPES.TSUnknownKeyword:
795
+ case utils_1.AST_NODE_TYPES.TSTemplateLiteralType:
796
+ return 'open';
797
+ default:
798
+ return 'unknown';
799
+ }
800
+ };
801
+ /**
802
+ * The key type parameter of a Record-shaped annotation, read through the
803
+ * wrappers that keep its key domain (`Readonly`, `Partial`) and through a
804
+ * bare in-file alias (`type Lookup = Record<K, V>`). Anything else — a type
805
+ * literal with an index signature, a Map, an imported alias — yields null:
806
+ * the annotation then makes no syntactically checkable claim about which
807
+ * keys exist.
808
+ */
809
+ const recordKeyTypeOf = (typeNode, anchor, seen = new Set()) => {
810
+ // `Record<K, V> | undefined` — the natural annotation for a receiver
811
+ // reached through `?.` — keys exactly what `Record<K, V>` keys: a nullish
812
+ // receiver short-circuits (or throws), it never indexes anything else.
813
+ if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType) {
814
+ const substantive = typeNode.types.filter((member) => member.type !== utils_1.AST_NODE_TYPES.TSUndefinedKeyword &&
815
+ member.type !== utils_1.AST_NODE_TYPES.TSNullKeyword);
816
+ return substantive.length === 1
817
+ ? recordKeyTypeOf(substantive[0], anchor, seen)
818
+ : null;
819
+ }
820
+ if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
821
+ typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
822
+ return null;
823
+ }
824
+ const { name } = typeNode.typeName;
825
+ const args = typeNode.typeParameters?.params;
826
+ if (name === 'Record') {
827
+ return args?.length === 2 ? args[0] : null;
828
+ }
829
+ if (RECORD_KEY_PRESERVING_WRAPPERS.has(name) && args?.length === 1) {
830
+ return recordKeyTypeOf(args[0], anchor, seen);
831
+ }
832
+ if (args) {
833
+ return null;
834
+ }
835
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, anchor), name);
836
+ if (!variable || seen.has(variable) || variable.defs.length !== 1) {
837
+ return null;
838
+ }
839
+ const def = variable.defs[0];
840
+ if (def.node.type !== utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
841
+ return null;
842
+ }
843
+ return recordKeyTypeOf(def.node.typeAnnotation, anchor, new Set(seen).add(variable));
844
+ };
845
+ /**
846
+ * Whether the Record's declared key domain covers the key's declared type,
847
+ * so that TypeScript itself rejects any key value outside the record's
848
+ * declared keys.
849
+ *
850
+ * Two spellings prove it:
851
+ *
852
+ * - **The same type reference on both sides** (`kind: Kind` into
853
+ * `Record<Kind, V>`). Name identity makes the domains equal whatever the
854
+ * alias holds — an imported alias included — so resolution is consulted
855
+ * only to refuse a domain that resolves to something open (`type K =
856
+ * string` re-opens the very surface this rule guards) or to a literal
857
+ * union naming a prototype field.
858
+ * - **Literal unions the syntax can compare** (`kind: 'live' | 'simulated'`
859
+ * into `Record<'live' | 'simulated', V>`, or a narrowing of it): every
860
+ * literal the key admits must be a declared record key, and none of them
861
+ * may name the prototype surface.
862
+ */
863
+ const recordKeyCovers = (keyAnnotation, recordKeyType, anchor) => {
864
+ const namesNoPrototypeField = (domain) => ![...domain].some((value) => PROTOTYPE_SURFACE_NAMES.has(value));
865
+ if (keyAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
866
+ recordKeyType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
867
+ !keyAnnotation.typeParameters &&
868
+ !recordKeyType.typeParameters &&
869
+ context.sourceCode.getText(keyAnnotation.typeName) ===
870
+ context.sourceCode.getText(recordKeyType.typeName)) {
871
+ const domain = keyDomainOf(keyAnnotation, anchor, new Set());
872
+ if (domain === 'open') {
873
+ return false;
874
+ }
875
+ return typeof domain === 'string' || namesNoPrototypeField(domain);
876
+ }
877
+ const keyDomain = keyDomainOf(keyAnnotation, anchor, new Set());
878
+ if (typeof keyDomain === 'string' ||
879
+ keyDomain.size === 0 ||
880
+ !namesNoPrototypeField(keyDomain)) {
881
+ return false;
882
+ }
883
+ const recordDomain = keyDomainOf(recordKeyType, anchor, new Set());
884
+ if (typeof recordDomain === 'string') {
885
+ return false;
886
+ }
887
+ return [...keyDomain].every((value) => recordDomain.has(value));
888
+ };
889
+ /**
890
+ * The type annotations declared on the binding an identifier resolves to.
891
+ * Every definition must carry one on the binding name itself — an
892
+ * annotation on a binding is what TypeScript checks every write against, so
893
+ * it holds for the lookup no matter which statement assigned last. A
894
+ * destructured binding, an unannotated declarator, an import: null, because
895
+ * nothing constrains what the identifier holds.
896
+ */
897
+ const declaredAnnotationsOf = (identifier) => {
898
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, identifier), identifier.name);
899
+ if (!variable || variable.defs.length === 0) {
900
+ return null;
901
+ }
902
+ const annotations = [];
903
+ for (const def of variable.defs) {
904
+ const bindingName = def.name;
905
+ if (bindingName.type !== utils_1.AST_NODE_TYPES.Identifier ||
906
+ !bindingName.typeAnnotation) {
907
+ return null;
908
+ }
909
+ annotations.push(bindingName.typeAnnotation.typeAnnotation);
910
+ }
911
+ return annotations;
912
+ };
913
+ /**
914
+ * Whether `object[key]` is a lookup the compiler already bounds: the object
915
+ * is a binding annotated `Record<K, V>` and the key a binding whose
916
+ * declared type `K` covers (#1875). Such a lookup cannot reach the
917
+ * prototype surface without the code failing to compile, so `assertSafe`
918
+ * would validate nothing — and it is not identity on the values that DO
919
+ * slip past a declared type at runtime (data crossing a persistence or
920
+ * version boundary): the plain lookup degrades to `undefined` where the
921
+ * wrapped one throws, which is precisely the semantic change that turned a
922
+ * graceful render fallback into a render-time crash. Both sides must be
923
+ * annotated: an `any`-typed or unannotated key indexes into any Record
924
+ * without a compile error, so the record annotation alone proves nothing.
925
+ */
926
+ const isCompilerBoundedLookup = (node, key) => {
927
+ if (node.object.type !== utils_1.AST_NODE_TYPES.Identifier) {
928
+ return false;
929
+ }
930
+ const objectAnnotations = declaredAnnotationsOf(node.object);
931
+ if (!objectAnnotations) {
932
+ return false;
933
+ }
934
+ const recordKeyTypes = [];
935
+ for (const annotation of objectAnnotations) {
936
+ const recordKeyType = recordKeyTypeOf(annotation, node);
937
+ if (!recordKeyType) {
938
+ return false;
939
+ }
940
+ recordKeyTypes.push(recordKeyType);
941
+ }
942
+ const keyAnnotations = declaredAnnotationsOf(key);
943
+ if (!keyAnnotations) {
944
+ return false;
945
+ }
946
+ return keyAnnotations.every((keyAnnotation) => recordKeyTypes.every((recordKeyType) => recordKeyCovers(keyAnnotation, recordKeyType, node)));
947
+ };
623
948
  /**
624
949
  * Whether the syntax alone proves the key is a number. `__proto__`,
625
950
  * `constructor` and `prototype` are never the string form of a number, so a
@@ -838,6 +1163,12 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
838
1163
  if (isAssertSafeValidatedIdentifier(property)) {
839
1164
  return;
840
1165
  }
1166
+ // A typed discriminant indexing a Record whose declared keys cover
1167
+ // its type is compile-time bounded; wrapping it would turn a total
1168
+ // lookup into a throwing one (#1875).
1169
+ if (isCompilerBoundedLookup(node, property)) {
1170
+ return;
1171
+ }
841
1172
  const propText = context.sourceCode.getText(property);
842
1173
  reportWrittenKey(written, property, propText);
843
1174
  return;
@@ -119,17 +119,49 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
119
119
  function needsQuoting(key) {
120
120
  return key.includes('.') || !/^(?:[$_A-Za-z][$\w]*)$/u.test(key);
121
121
  }
122
+ // A method shorthand elides the `function` keyword, and its
123
+ // FunctionExpression range starts at the parameter list, so copying the
124
+ // value's text verbatim emits `() { … }`, which is not an expression. The
125
+ // member is re-emitted with the keyword the shorthand leaves out, matching
126
+ // what the `key: function () {}` spelling already produces.
127
+ //
128
+ // `super` is the one binding the two spellings do not share: it resolves
129
+ // through the enclosing object literal's home object, which a function
130
+ // expression has none of, so such a method is declined rather than
131
+ // rewritten into code that cannot resolve it. Any nested `super` counts,
132
+ // because narrowing the scan to the method's own body would have to model
133
+ // which inner forms rebind it.
134
+ function getMethodValueText(value, sourceCode) {
135
+ const referencesSuper = sourceCode
136
+ .getTokens(value)
137
+ .some((token) => token.type === utils_1.AST_TOKEN_TYPES.Keyword && token.value === 'super');
138
+ if (referencesSuper) {
139
+ return undefined;
140
+ }
141
+ return `${value.async ? 'async ' : ''}function${value.generator ? '*' : ''} ${sourceCode.getText(value)}`;
142
+ }
143
+ // Text a nested leaf contributes to its FieldPath entry, or undefined when
144
+ // the value has no expression-position equivalent.
145
+ function getFlattenedValueText(property, sourceCode) {
146
+ if (property.method &&
147
+ property.value.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
148
+ return getMethodValueText(property.value, sourceCode);
149
+ }
150
+ return sourceCode.getText(property.value);
151
+ }
122
152
  // Collect the FieldPath entries a nested property flattens into, or bail out
123
153
  // when flattening would silently drop payload data (spreads, computed keys,
124
- // accessors/methods, unsupported key literals) or would produce nothing at
125
- // all. Bailing leaves the report in place so the developer flattens by hand
154
+ // accessors, unsupported key literals) or would produce nothing at all.
155
+ // Bailing leaves the report in place so the developer flattens by hand
126
156
  // instead of receiving a fix that deletes fields or emits invalid syntax.
127
157
  function collectFieldPathEntries(obj, prefix, sourceCode) {
128
158
  const entries = [];
129
159
  for (const property of obj.properties) {
160
+ // A getter or setter is declined even though it is spelled like a
161
+ // method: its body runs on access rather than holding a value, so no
162
+ // FieldPath entry can carry it
130
163
  if (property.type !== utils_1.AST_NODE_TYPES.Property ||
131
164
  property.computed ||
132
- property.method ||
133
165
  property.kind !== 'init') {
134
166
  return null;
135
167
  }
@@ -146,7 +178,11 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
146
178
  entries.push(...nestedEntries);
147
179
  continue;
148
180
  }
149
- entries.push([fullKey, sourceCode.getText(property.value)]);
181
+ const valueText = getFlattenedValueText(property, sourceCode);
182
+ if (valueText === undefined) {
183
+ return null;
184
+ }
185
+ entries.push([fullKey, valueText]);
150
186
  }
151
187
  return entries.length > 0 ? entries : null;
152
188
  }
@@ -43,6 +43,9 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
43
43
  'removeAttributeNS',
44
44
  'toHaveAttribute',
45
45
  ]);
46
+ // A single identifier token — no whitespace, no punctuation. Prose is a
47
+ // phrase, so anything matching this is a name rather than displayed text.
48
+ const IDENTIFIER_TOKEN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
46
49
  /**
47
50
  * Check if a node is in a context that should be excluded from the rule
48
51
  * (e.g., parameter names, property names, type definitions)
@@ -116,6 +119,21 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
116
119
  return true;
117
120
  }
118
121
  }
122
+ // Check if the node is a lone identifier token listed in an array
123
+ // literal, e.g. ['id', 'broadcastTest'] as const. An array of bare
124
+ // identifiers is a key/field-name list — the array spelling of the object
125
+ // keys this rule already leaves alone — so 'ID' would name a key that
126
+ // does not exist. The carve-out requires the *whole* element to be one
127
+ // identifier, which keeps a phrase such as ['Enter your id', 'Name']
128
+ // reported: prose carries whitespace or punctuation, a key name does not.
129
+ if (node.type === utils_1.AST_NODE_TYPES.Literal &&
130
+ typeof node.value === 'string' &&
131
+ node.parent &&
132
+ node.parent.type === utils_1.AST_NODE_TYPES.ArrayExpression &&
133
+ node.parent.elements.includes(node) &&
134
+ IDENTIFIER_TOKEN.test(node.value)) {
135
+ return true;
136
+ }
119
137
  // Check if the node is a string literal used for property access
120
138
  // This handles cases like obj['id'] or OverwolfGame['id']
121
139
  if (node.parent &&
@@ -41,6 +41,23 @@ const DEFAULT_OPTIONS = {
41
41
  '@vitest-environment',
42
42
  ],
43
43
  };
44
+ /**
45
+ * What the consumer's options are deep-merged over, with every null-valued
46
+ * default removed.
47
+ *
48
+ * `applyDefault` merges before `create` runs, and its `deepMerge` classifies
49
+ * `null` as an object (`typeof null === 'object'`), so a key that is null on
50
+ * both sides is recursed into and reaches `Object.keys(null)`. That throws
51
+ * while LOADING the rule, which aborts the lint for the whole file and takes
52
+ * every other rule with it. `headerTemplate` is exactly that shape: `null` is
53
+ * both its schema-legal value and its documented default, so a consumer who
54
+ * writes the documented default out explicitly crashes their own run.
55
+ *
56
+ * Omitting the key leaves the merge nothing to recurse into. Nothing is lost —
57
+ * `normalizeOptions` reads the default straight from `DEFAULT_OPTIONS`, so an
58
+ * absent key and a null one already resolve identically.
59
+ */
60
+ const MERGEABLE_DEFAULT_OPTIONS = Object.fromEntries(Object.entries(DEFAULT_OPTIONS).filter(([, value]) => value !== null));
44
61
  /**
45
62
  * Maximum number of characters to scan at the beginning of a file to detect generated markers.
46
63
  */
@@ -381,7 +398,7 @@ exports.enforceUniqueCursorHeaders = (0, createRule_1.createRule)({
381
398
  splitHeaderFragment: 'Cursor header metadata is split across adjacent comment blocks → Fragmented headers are easy to miss and let required tags drift out of sync → Merge the fragments into a single top-of-file header containing: {{tags}}.',
382
399
  },
383
400
  },
384
- defaultOptions: [DEFAULT_OPTIONS],
401
+ defaultOptions: [MERGEABLE_DEFAULT_OPTIONS],
385
402
  create(context, [userOptions]) {
386
403
  const options = normalizeOptions(userOptions);
387
404
  const fileName = context.getFilename();
@@ -414,17 +414,20 @@ exports.noInlineComponentProp = (0, createRule_1.createRule)({
414
414
  if (!definition)
415
415
  return;
416
416
  const defNode = definition.node;
417
- if (!defNode ||
418
- defNode.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
419
- !defNode.init ||
420
- defNode.init.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
417
+ if (!defNode || defNode.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
418
+ return;
419
+ }
420
+ // `global-const-style` autofixes a module-scope object literal to
421
+ // `as const`, so reading `init` raw lets one fixer silence this rule.
422
+ const holder = unwrapExpression(defNode.init);
423
+ if (!holder || holder.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
421
424
  return;
422
425
  }
423
426
  if (resolvedOptions.allowModuleScopeFactories &&
424
427
  isStableForConsumer(defNode, consumerFunction)) {
425
428
  return;
426
429
  }
427
- const fnNode = findObjectPropertyFunction(defNode.init, member.property.name);
430
+ const fnNode = findObjectPropertyFunction(holder, member.property.name);
428
431
  if (fnNode &&
429
432
  isComponentLikeFunction(fnNode, context, member.property.name)) {
430
433
  report(member, propName, member.property.name);
@@ -68,18 +68,33 @@ exports.noObjectValuesOnStrings = (0, createRule_1.createRule)({
68
68
  node.callee.property.name === 'values' &&
69
69
  node.arguments.length > 0);
70
70
  }
71
+ /**
72
+ * `a?.b()` parses as a ChainExpression wrapping the call. The wrapper is
73
+ * ESTree-only, so it has no TypeScript node: leaving it in place defeats
74
+ * both the syntactic tests below and `getTypeAtLocation`, and a nullable
75
+ * receiver is exactly where `?.` gets written.
76
+ */
77
+ function unwrapChain(node) {
78
+ let current = node;
79
+ while (current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
80
+ current = current.expression;
81
+ }
82
+ return current;
83
+ }
71
84
  /**
72
85
  * Checks if a node is a string literal or template literal
73
86
  */
74
87
  function isStringLiteral(node) {
75
- return ((node.type === utils_1.AST_NODE_TYPES.Literal &&
76
- typeof node.value === 'string') ||
77
- node.type === utils_1.AST_NODE_TYPES.TemplateLiteral);
88
+ const inner = unwrapChain(node);
89
+ return ((inner.type === utils_1.AST_NODE_TYPES.Literal &&
90
+ typeof inner.value === 'string') ||
91
+ inner.type === utils_1.AST_NODE_TYPES.TemplateLiteral);
78
92
  }
79
93
  /**
80
94
  * Checks if a node is likely to produce a string value based on AST patterns
81
95
  */
82
- function isLikelyStringExpression(node) {
96
+ function isLikelyStringExpression(maybeChained) {
97
+ const node = unwrapChain(maybeChained);
83
98
  // Check for string concatenation
84
99
  if (node.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
85
100
  node.operator === '+' &&
@@ -190,8 +205,10 @@ exports.noObjectValuesOnStrings = (0, createRule_1.createRule)({
190
205
  CallExpression(node) {
191
206
  // Check if the call is Object.values()
192
207
  if (isObjectValuesCall(node)) {
193
- const argument = node.arguments[0];
194
- const argumentText = sourceCode.getText(argument);
208
+ // The message quotes what the author wrote, so text comes from the
209
+ // original node while every test below reads through the chain.
210
+ const argumentText = sourceCode.getText(node.arguments[0]);
211
+ const argument = unwrapChain(node.arguments[0]);
195
212
  // Quick check for string literals and template literals
196
213
  if (isStringLiteral(argument)) {
197
214
  context.report({
@@ -421,8 +421,16 @@ exports.noPassthroughGetters = (0, createRule_1.createRule)({
421
421
  if (node.type === 'ConditionalExpression') {
422
422
  return true;
423
423
  }
424
- // Check for optional chaining like this.settings?.property
425
- if (node.type === 'MemberExpression' && node.optional) {
424
+ // Check for optional chaining like this.settings?.property. `a?.b` parses
425
+ // as a ChainExpression wrapping the member access, so the wrapper has to
426
+ // be stripped here or this arm never fires — the shape stays silent only
427
+ // because the member walker cannot follow it, which would reverse the
428
+ // moment that walker learns to.
429
+ let unchained = node;
430
+ while (unchained.type === 'ChainExpression') {
431
+ unchained = unchained.expression;
432
+ }
433
+ if (unchained.type === 'MemberExpression' && unchained.optional) {
426
434
  return true;
427
435
  }
428
436
  // The nullish coalescing check is already covered by the LogicalExpression check above
@@ -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
  },