@blumintinc/eslint-plugin-blumint 1.20.108 → 1.20.110

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/README.md CHANGED
@@ -104,7 +104,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
104
104
  | [enforce-exported-function-types](docs/rules/enforce-exported-function-types.md) | Enforce exporting types for function props and return values | ✅ | | 🔧 | | |
105
105
  | [enforce-f-extension-for-entry-points](docs/rules/enforce-f-extension-for-entry-points.md) | Enforce .f.ts extension for entry points | ✅ | | | | |
106
106
  | [enforce-fieldpath-syntax-in-docsetter](docs/rules/enforce-fieldpath-syntax-in-docsetter.md) | Enforce the use of Firestore FieldPath syntax when passing documentData into DocSetter. Instead of using nested object syntax, developers should use dot notation for deeply nested fields. | ✅ | | 🔧 | | |
107
- | [enforce-firestore-doc-ref-generic](docs/rules/enforce-firestore-doc-ref-generic.md) | Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup | ✅ | | | | 💭 |
107
+ | [enforce-firestore-doc-ref-generic](docs/rules/enforce-firestore-doc-ref-generic.md) | Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup | ✅ | | | | |
108
108
  | [enforce-firestore-facade](docs/rules/enforce-firestore-facade.md) | Enforce usage of Firestore facades instead of direct Firestore methods | ✅ | | | | |
109
109
  | [enforce-firestore-path-utils](docs/rules/enforce-firestore-path-utils.md) | Enforce usage of utility functions for Firestore paths to ensure type safety, maintainability, and consistent path construction. This prevents errors from manual string concatenation and makes path changes easier to manage. | ✅ | | | | |
110
110
  | [enforce-firestore-rules-get-access](docs/rules/enforce-firestore-rules-get-access.md) | Ensure Firestore security rules use .get() with a default value instead of direct field access comparisons (e.g., resource.data.fieldX.fieldY != null). | ✅ | | 🔧 | | |
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.108',
226
+ version: '1.20.110',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -339,6 +339,11 @@ module.exports = {
339
339
  '@blumintinc/blumint/prefer-destructuring-no-class': 'error',
340
340
  '@blumintinc/blumint/enforce-render-hits-memoization': 'error',
341
341
  '@blumintinc/blumint/enforce-transform-memoization': 'error',
342
+ // Off because it demands the opposite spelling from the enabled
343
+ // prefer-fragment-shorthand, and because the consumer's codebase
344
+ // still violates it while its sync reverts on any report. The measured
345
+ // impact and the criterion that graduates it to 'error' are recorded in
346
+ // docs/rules/prefer-fragment-component.md.
342
347
  '@blumintinc/blumint/prefer-fragment-component': 'off',
343
348
  '@blumintinc/blumint/react-usememo-should-be-component': 'error',
344
349
  '@blumintinc/blumint/no-unnecessary-verb-suffix': 'error',
@@ -21,7 +21,12 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
21
21
  docs: {
22
22
  description: 'Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup',
23
23
  recommended: 'error',
24
- requiresTypeChecking: true,
24
+ // Every check here is syntactic: generics are read off the AST and named
25
+ // generics are resolved against declarations in the same file. Declaring
26
+ // type information would be a false promise twice over — it tells
27
+ // consumers they need `parserOptions.project`, and it exempts this rule
28
+ // from guards that skip rules a program-less `Linter` cannot exercise.
29
+ requiresTypeChecking: false,
25
30
  },
26
31
  schema: [],
27
32
  messages: {
@@ -83,6 +83,29 @@ function isInsideMockFactory(node) {
83
83
  }
84
84
  return false;
85
85
  }
86
+ /**
87
+ * The class a method belongs to, reached through its `ClassBody`.
88
+ */
89
+ function enclosingClass(node) {
90
+ const body = node.parent;
91
+ return body?.type === utils_1.AST_NODE_TYPES.ClassBody ? body.parent : undefined;
92
+ }
93
+ /**
94
+ * Whether the method's own class is written as an expression — `const C = class
95
+ * {}`, a class in argument position, a class assigned to a property — rather
96
+ * than as a declaration.
97
+ *
98
+ * Under `experimentalDecorators`, TypeScript accepts a member decorator only
99
+ * inside a class DECLARATION: the same `@Memoize()` that compiles inside `class
100
+ * C {}`, `export class C {}` or `export default class {}` is `TS1206:
101
+ * Decorators are not valid here.` inside a class expression. An emitted
102
+ * decorator there breaks the consumer's build, so the report stands without a
103
+ * fix and the author restructures deliberately — hoisting the class to a
104
+ * declaration makes the decorator legal.
105
+ */
106
+ function isInsideClassExpression(node) {
107
+ return enclosingClass(node)?.type === utils_1.AST_NODE_TYPES.ClassExpression;
108
+ }
86
109
  /**
87
110
  * Whether a declared return type annotation promises no value: `void` or
88
111
  * `Promise<void>`.
@@ -351,6 +374,14 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
351
374
  if (isInsideMockFactory(node)) {
352
375
  return null;
353
376
  }
377
+ // A decorator is legal only on a member of a class declaration, so
378
+ // decorating a class expression's method emits code the consumer's
379
+ // compiler rejects outright (TS1206). Declining ahead of the import
380
+ // carrier claim below leaves the import to a violation that does
381
+ // fix.
382
+ if (isInsideClassExpression(node)) {
383
+ return null;
384
+ }
354
385
  const fixes = [];
355
386
  const sourceCode = context.sourceCode;
356
387
  // Determine which identifier to use for the decorator
@@ -6,6 +6,85 @@ const createRule_1 = require("../utils/createRule");
6
6
  // Define the operations that are considered reads and writes
7
7
  const READ_OPERATIONS = new Set(['get']);
8
8
  const WRITE_OPERATIONS = new Set(['set', 'update', 'delete']);
9
+ /**
10
+ * Helpers that validate a property key and hand back that very key, so a call
11
+ * to one names exactly the method its argument names. `enforce-assert-safe-object-key`
12
+ * is `error` in the same recommended config and its fixer wraps computed keys in
13
+ * `assertSafe(...)`, which means this shape is machine-generated from ordinary
14
+ * `transaction[methodName]` code rather than hand-written.
15
+ */
16
+ const KEY_ASSERTION_HELPERS = new Set(['assertSafe']);
17
+ /**
18
+ * Strips wrappers that erase at compile time or resolve to the key itself.
19
+ * `k as string`, `k satisfies string`, `<string>k` and `k!` emit nothing, and
20
+ * `await k` yields the same key, so none of them changes which method the
21
+ * lookup selects.
22
+ *
23
+ * The peel repeats because the wrappers nest: `(k as any)!`.
24
+ */
25
+ function unwrapErasedKey(node) {
26
+ let current = node;
27
+ for (;;) {
28
+ switch (current.type) {
29
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
30
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
31
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
32
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
33
+ current = current.expression;
34
+ break;
35
+ case utils_1.AST_NODE_TYPES.AwaitExpression:
36
+ current = current.argument;
37
+ break;
38
+ default:
39
+ return current;
40
+ }
41
+ }
42
+ }
43
+ /** True for `assertSafe(k)` and for a namespaced `utils.assertSafe(k)`. */
44
+ function isKeyAssertionCall(node) {
45
+ const { callee } = node;
46
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
47
+ return KEY_ASSERTION_HELPERS.has(callee.name);
48
+ }
49
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
50
+ !callee.computed &&
51
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
52
+ KEY_ASSERTION_HELPERS.has(callee.property.name));
53
+ }
54
+ /**
55
+ * Resolves the method a computed access selects: `transaction[<key>]()`.
56
+ *
57
+ * A key assertion returns its argument untouched, so reading through one is
58
+ * information-free — `transaction[assertSafe(k)]` must land on exactly the
59
+ * verdict `transaction[k]` lands on, definite name and all. Every other call
60
+ * may return something other than what it was handed, so its argument proves
61
+ * nothing about the method and the key counts as unresolved. That keeps the
62
+ * safety net on for any wrapper (`transaction[String(k)]`) while never minting
63
+ * a definite read/write verdict out of a call whose result is unknown.
64
+ */
65
+ function resolveComputedKey(property) {
66
+ let current = unwrapErasedKey(property);
67
+ for (;;) {
68
+ if (current.type === utils_1.AST_NODE_TYPES.Literal &&
69
+ typeof current.value === 'string') {
70
+ return { kind: 'name', name: current.value };
71
+ }
72
+ if (current.type === utils_1.AST_NODE_TYPES.Identifier) {
73
+ return { kind: 'unresolved' };
74
+ }
75
+ if (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
76
+ const [argument] = current.arguments;
77
+ if (!isKeyAssertionCall(current) ||
78
+ current.arguments.length !== 1 ||
79
+ argument.type === utils_1.AST_NODE_TYPES.SpreadElement) {
80
+ return { kind: 'unresolved' };
81
+ }
82
+ current = unwrapErasedKey(argument);
83
+ continue;
84
+ }
85
+ return { kind: 'opaque' };
86
+ }
87
+ }
9
88
  exports.firestoreTransactionReadsBeforeWrites = (0, createRule_1.createRule)({
10
89
  name: 'firestore-transaction-reads-before-writes',
11
90
  meta: {
@@ -119,18 +198,19 @@ exports.firestoreTransactionReadsBeforeWrites = (0, createRule_1.createRule)({
119
198
  // Normal property access: transaction.get()
120
199
  methodName = property.name;
121
200
  }
122
- else if (callee.computed &&
123
- property.type === utils_1.AST_NODE_TYPES.Literal &&
124
- typeof property.value === 'string') {
125
- // Computed property access with string literal: transaction['get']
126
- methodName = property.value;
127
- }
128
- else if (callee.computed &&
129
- property.type === utils_1.AST_NODE_TYPES.Identifier) {
130
- // Computed property access with variable: transaction[methodName]
131
- // This is tricky to analyze statically. For now, we'll be conservative
132
- // and assume it could be any method. We'll handle this in the caller.
133
- return { isRead: true, isWrite: true, methodName: null }; // Could be either - let caller decide
201
+ else if (callee.computed) {
202
+ // Computed property access: transaction['get'], transaction[methodName],
203
+ // transaction[assertSafe(methodName)]. A key that survives resolution
204
+ // as a definite string names the method; one that cannot be resolved
205
+ // could be any method, so it is answered conservatively and the caller
206
+ // decides.
207
+ const resolved = resolveComputedKey(property);
208
+ if (resolved.kind === 'unresolved') {
209
+ return { isRead: true, isWrite: true, methodName: null };
210
+ }
211
+ if (resolved.kind === 'name') {
212
+ methodName = resolved.name;
213
+ }
134
214
  }
135
215
  if (!methodName) {
136
216
  return { isRead: false, isWrite: false, methodName: null };
@@ -154,7 +234,14 @@ exports.firestoreTransactionReadsBeforeWrites = (0, createRule_1.createRule)({
154
234
  if (callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
155
235
  return `${objectName}[${callee.property.name}]`;
156
236
  }
157
- return `${objectName}[computed]`;
237
+ // A wrapped key such as assertSafe(methodName) is quoted verbatim so
238
+ // the message names text that exists in the file and can be searched
239
+ // for, rather than the resolved key the reader never wrote.
240
+ const keyText = context
241
+ .getSourceCode()
242
+ .getText(callee.property)
243
+ .replace(/\s+/g, ' ');
244
+ return `${objectName}[${keyText}]`;
158
245
  }
159
246
  if (callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
160
247
  return `${objectName}.${callee.property.name}`;
@@ -3,6 +3,77 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noRedundantUseCallbackWrapper = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const LATEST_CALLBACK_MODULE = 'use-latest-callback';
8
+ const LATEST_CALLBACK_HOOK = 'useLatestCallback';
9
+ const MEMO_HOOK = 'useMemo';
10
+ /**
11
+ * Type-level wrappers carry no runtime value, so a callback that leaves a
12
+ * memoizing call through a cast is the same callback. Reading through them keeps
13
+ * the proof from depending on whether the author spelled an annotation.
14
+ */
15
+ const TYPE_ONLY_WRAPPERS = new Set([
16
+ utils_1.AST_NODE_TYPES.TSAsExpression,
17
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
18
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
19
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
20
+ utils_1.AST_NODE_TYPES.TSInstantiationExpression,
21
+ ]);
22
+ function unwrapValueExpression(node) {
23
+ let current = node;
24
+ while (current.type === utils_1.AST_NODE_TYPES.ChainExpression ||
25
+ TYPE_ONLY_WRAPPERS.has(current.type)) {
26
+ current = current.expression;
27
+ }
28
+ return current;
29
+ }
30
+ /**
31
+ * The bare name a callee resolves to, collapsing the namespaced spelling so
32
+ * `React.useCallback` and `useCallback` answer alike.
33
+ */
34
+ function calleeNameOf(callee) {
35
+ const unwrapped = unwrapValueExpression(callee);
36
+ if (unwrapped.type === utils_1.AST_NODE_TYPES.Identifier) {
37
+ return unwrapped.name;
38
+ }
39
+ if (unwrapped.type === utils_1.AST_NODE_TYPES.MemberExpression &&
40
+ !unwrapped.computed &&
41
+ unwrapped.property.type === utils_1.AST_NODE_TYPES.Identifier) {
42
+ return unwrapped.property.name;
43
+ }
44
+ return null;
45
+ }
46
+ function isFunctionLiteral(node) {
47
+ return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
48
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression);
49
+ }
50
+ /**
51
+ * Whether a `useMemo` factory demonstrably yields a function.
52
+ *
53
+ * `useMemo` memoizes any value, so its result is a memoized *callback* only when
54
+ * the factory produces one. Only a factory that hands back a function literal
55
+ * proves that in-source; a factory returning a call result, a conditional or a
56
+ * value assembled across several statements might yield anything, and this rule
57
+ * prefers a false negative to guessing.
58
+ */
59
+ function producesFunction(factory) {
60
+ if (!factory || !isFunctionLiteral(factory)) {
61
+ return false;
62
+ }
63
+ const fn = factory;
64
+ if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
65
+ return isFunctionLiteral(unwrapValueExpression(fn.body));
66
+ }
67
+ const statements = fn.body.body;
68
+ if (statements.length !== 1) {
69
+ return false;
70
+ }
71
+ const [only] = statements;
72
+ if (only.type !== utils_1.AST_NODE_TYPES.ReturnStatement || !only.argument) {
73
+ return false;
74
+ }
75
+ return isFunctionLiteral(unwrapValueExpression(only.argument));
76
+ }
6
77
  function isHookLikeName(name) {
7
78
  return name.startsWith('use');
8
79
  }
@@ -100,7 +171,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
100
171
  },
101
172
  ],
102
173
  messages: {
103
- redundantWrapper: 'useCallback is wrapping memoized callback "{{callbackName}}", adding a redundant dependency array without improving stability. Pass the hook/context callback directly so React keeps the original stable reference and avoids wrapper allocations and dependency drift.',
174
+ // The wrapper is named rather than hardcoded: the rule reports
175
+ // `useLatestCallback` too, which has no dependency array, so a message
176
+ // asserting one would describe code the reader cannot find.
177
+ redundantWrapper: '{{wrapper}} is wrapping memoized callback "{{callbackName}}", adding a redundant memoization layer without improving stability. Pass the hook/context callback directly so React keeps the original stable reference and avoids wrapper allocations and dependency drift.',
104
178
  },
105
179
  },
106
180
  defaultOptions: [{}],
@@ -109,10 +183,116 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
109
183
  const knownHooks = new Set(option.memoizedHookNames ?? []);
110
184
  const assumeAllUseAreMemoized = option.assumeAllUseAreMemoized === true;
111
185
  const sourceCode = context.sourceCode;
186
+ // Every callee that memoizes the callback handed to it. `useLatestCallback`
187
+ // belongs here because `use-latest-callback` — 'error' in the same
188
+ // recommended config, and fixable — rewrites every `useCallback(fn, deps)`
189
+ // into `useLatestCallback(fn)`. The wrapper it produces is the very
190
+ // construct this rule objects to, still allocating a fresh arrow around an
191
+ // already stable callback, so without this entry one `eslint --fix` renames
192
+ // the violation out of view while leaving it byte-for-byte intact — and the
193
+ // config mandating that spelling means it is also written by hand (#1726).
194
+ const wrapperNames = new Set(['useCallback', LATEST_CALLBACK_HOOK]);
195
+ /**
196
+ * The wrapper's name if this callee is one, else null. Reading the name
197
+ * rather than a boolean lets the report say which wrapper it found, since
198
+ * the local binding need not be spelled `useLatestCallback` at all.
199
+ */
200
+ const wrapperNameOf = (callee) => {
201
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
202
+ return wrapperNames.has(callee.name) ? callee.name : null;
203
+ }
204
+ // Namespaced spelling, e.g. React.useCallback
205
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
206
+ !callee.computed &&
207
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
208
+ return wrapperNames.has(callee.property.name)
209
+ ? callee.property.name
210
+ : null;
211
+ }
212
+ return null;
213
+ };
214
+ /**
215
+ * Whether the binding this identifier resolves to holds a callback whose
216
+ * memoization is visible in this very file.
217
+ *
218
+ * `memoizedHookNames` exists for callbacks whose stability only the consumer
219
+ * knows about. A `const` initialized from `useCallback`, `useLatestCallback`
220
+ * or a `useMemo` that yields a function needs no such knowledge: the
221
+ * memoizing call sits in the same source, so wrapping its result is provably
222
+ * redundant and the rule reports it under the default options — without
223
+ * which the rule can report nothing at all in a config that does not set
224
+ * `memoizedHookNames`.
225
+ *
226
+ * The binding is resolved through scope analysis rather than matched by
227
+ * name, because a name set cannot tell the memoized `inner` of one component
228
+ * from the `inner` prop of the next, and would report the prop — the wrapper
229
+ * that is the only thing making it stable.
230
+ */
231
+ const isLocallyMemoizedCallback = (identifier, wrapperCall) => {
232
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, identifier), identifier.name);
233
+ if (!variable || variable.defs.length !== 1) {
234
+ return false;
235
+ }
236
+ const declarator = variable.defs[0].node;
237
+ if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
238
+ declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
239
+ !declarator.init) {
240
+ return false;
241
+ }
242
+ // A rebindable declaration breaks the proof: the value read at the wrapper
243
+ // need not be the one the memoizing call produced.
244
+ const declaration = declarator.parent;
245
+ if (declaration?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
246
+ declaration.kind !== 'const') {
247
+ return false;
248
+ }
249
+ // A wrapper sitting inside the initializer it reads is self-referential,
250
+ // and collapsing it would emit `const x = x`.
251
+ if (wrapperCall.range[0] >= declarator.range[0] &&
252
+ wrapperCall.range[1] <= declarator.range[1]) {
253
+ return false;
254
+ }
255
+ const init = unwrapValueExpression(declarator.init);
256
+ if (init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
257
+ return false;
258
+ }
259
+ const initName = calleeNameOf(init.callee);
260
+ if (!initName) {
261
+ return false;
262
+ }
263
+ // Every wrapper this rule reports is itself a memoizing call, so the same
264
+ // set answers both questions: what makes a callback stable, and what
265
+ // redundantly re-wraps one that already is.
266
+ if (wrapperNames.has(initName)) {
267
+ return true;
268
+ }
269
+ return initName === MEMO_HOOK && producesFunction(init.arguments[0]);
270
+ };
112
271
  // Track identifiers coming from hook-like calls
113
272
  const hookReturnObjects = new Set(); // variables assigned to a hook call result (object or function)
114
273
  const hookReturnProps = new Set(); // properties destructured from a hook call result
115
274
  return {
275
+ ImportDeclaration(node) {
276
+ // The module's sole export is the hook, so its DEFAULT specifier binds
277
+ // it under whatever local name the file chose — a shape a set of bare
278
+ // hook names cannot see. `use-latest-callback`'s own fixer picks that
279
+ // name with `freeImportName`, falling back to `useLatestCallback2` when
280
+ // `useLatestCallback` is already taken in the file, so the alias is
281
+ // authored by the sibling fixer rather than being hypothetical.
282
+ if (node.source.value !== LATEST_CALLBACK_MODULE ||
283
+ (node.importKind && node.importKind !== 'value')) {
284
+ return;
285
+ }
286
+ for (const specifier of node.specifiers) {
287
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
288
+ (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
289
+ specifier.importKind !== 'type' &&
290
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
291
+ specifier.imported.name === LATEST_CALLBACK_HOOK)) {
292
+ wrapperNames.add(specifier.local.name);
293
+ }
294
+ }
295
+ },
116
296
  VariableDeclarator(node) {
117
297
  if (!node.init)
118
298
  return;
@@ -145,17 +325,13 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
145
325
  }
146
326
  },
147
327
  CallExpression(node) {
148
- // Detect useCallback wrappers (including React.useCallback)
328
+ // Detect memoization wrappers (including React.useCallback and the
329
+ // useLatestCallback spelling the config's own fixer produces)
149
330
  const calleeNode = unwrapChainExpression(node.callee);
150
331
  if (!calleeNode)
151
332
  return;
152
- const isUseCallback = (calleeNode.type === utils_1.AST_NODE_TYPES.Identifier &&
153
- calleeNode.name === 'useCallback') ||
154
- (calleeNode.type === utils_1.AST_NODE_TYPES.MemberExpression &&
155
- !calleeNode.computed &&
156
- calleeNode.property.type === utils_1.AST_NODE_TYPES.Identifier &&
157
- calleeNode.property.name === 'useCallback');
158
- if (isUseCallback && node.arguments.length >= 1) {
333
+ const wrapper = wrapperNameOf(calleeNode);
334
+ if (wrapper && node.arguments.length >= 1) {
159
335
  const arg = node.arguments[0];
160
336
  const unwrappedArg = unwrapChainExpression(arg);
161
337
  // Case 1: useCallback(memoizedFn, ...) or useCallback(ctx.memoized, ...)
@@ -164,7 +340,8 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
164
340
  unwrappedArg.type === utils_1.AST_NODE_TYPES.MemberExpression)) {
165
341
  if ((unwrappedArg.type === utils_1.AST_NODE_TYPES.Identifier &&
166
342
  (hookReturnProps.has(unwrappedArg.name) ||
167
- hookReturnObjects.has(unwrappedArg.name))) ||
343
+ hookReturnObjects.has(unwrappedArg.name) ||
344
+ isLocallyMemoizedCallback(unwrappedArg, node))) ||
168
345
  (unwrappedArg.type === utils_1.AST_NODE_TYPES.MemberExpression &&
169
346
  unwrappedArg.object.type === utils_1.AST_NODE_TYPES.Identifier &&
170
347
  hookReturnObjects.has(unwrappedArg.object.name))) {
@@ -173,7 +350,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
173
350
  context.report({
174
351
  node,
175
352
  messageId: 'redundantWrapper',
176
- data: { callbackName: sourceCode.getText(unwrappedArg) },
353
+ data: {
354
+ wrapper,
355
+ callbackName: sourceCode.getText(unwrappedArg),
356
+ },
177
357
  fix: (fixer) => fixer.replaceText(node, replaceText),
178
358
  });
179
359
  }
@@ -182,7 +362,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
182
362
  context.report({
183
363
  node,
184
364
  messageId: 'redundantWrapper',
185
- data: { callbackName: sourceCode.getText(unwrappedArg) },
365
+ data: {
366
+ wrapper,
367
+ callbackName: sourceCode.getText(unwrappedArg),
368
+ },
186
369
  });
187
370
  }
188
371
  }
@@ -203,7 +386,8 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
203
386
  isIdentifierOrMemberOn(callee, hookReturnObjects)) ||
204
387
  (callee &&
205
388
  callee.type === utils_1.AST_NODE_TYPES.Identifier &&
206
- hookReturnProps.has(callee.name))) {
389
+ (hookReturnProps.has(callee.name) ||
390
+ isLocallyMemoizedCallback(callee, node)))) {
207
391
  if (bodyExpr.arguments.length > 0) {
208
392
  // Passing any arguments: treat as non-redundant (avoid false positives)
209
393
  return;
@@ -214,7 +398,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
214
398
  context.report({
215
399
  node,
216
400
  messageId: 'redundantWrapper',
217
- data: { callbackName: sourceCode.getText(callee) },
401
+ data: {
402
+ wrapper,
403
+ callbackName: sourceCode.getText(callee),
404
+ },
218
405
  fix: (fixer) => fixer.replaceText(node, replaceText),
219
406
  });
220
407
  }
@@ -223,7 +410,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
223
410
  context.report({
224
411
  node,
225
412
  messageId: 'redundantWrapper',
226
- data: { callbackName: sourceCode.getText(callee) },
413
+ data: {
414
+ wrapper,
415
+ callbackName: sourceCode.getText(callee),
416
+ },
227
417
  });
228
418
  }
229
419
  }
@@ -254,7 +444,8 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
254
444
  const callee = unwrapChainExpression(expr.callee);
255
445
  const isHookProp = callee &&
256
446
  callee.type === utils_1.AST_NODE_TYPES.Identifier &&
257
- hookReturnProps.has(callee.name);
447
+ (hookReturnProps.has(callee.name) ||
448
+ isLocallyMemoizedCallback(callee, node));
258
449
  const isHookObjMember = callee &&
259
450
  callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
260
451
  callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -272,7 +463,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
272
463
  context.report({
273
464
  node,
274
465
  messageId: 'redundantWrapper',
275
- data: { callbackName: sourceCode.getText(callee) },
466
+ data: {
467
+ wrapper,
468
+ callbackName: sourceCode.getText(callee),
469
+ },
276
470
  fix: (fixer) => fixer.replaceText(node, replaceText),
277
471
  });
278
472
  }
@@ -281,7 +475,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
281
475
  context.report({
282
476
  node,
283
477
  messageId: 'redundantWrapper',
284
- data: { callbackName: sourceCode.getText(callee) },
478
+ data: {
479
+ wrapper,
480
+ callbackName: sourceCode.getText(callee),
481
+ },
285
482
  });
286
483
  }
287
484
  }
@@ -111,7 +111,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
111
111
  type: 'suggestion',
112
112
  docs: {
113
113
  description: 'Require the Fragment named import instead of shorthand fragments or React.Fragment to keep fragments explicit and prop-friendly',
114
- recommended: 'error',
114
+ // `RuleMetaDataDocs` admits `false | 'error' | 'strict' | 'warn'` and has
115
+ // no `'off'` member, so `false` is this field's spelling of the `'off'`
116
+ // the recommended config ships. See the docs page for why it ships off
117
+ // and what graduates it to 'error'.
118
+ recommended: false,
115
119
  },
116
120
  fixable: 'code',
117
121
  schema: [],
@@ -91,20 +91,60 @@ function typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeName
91
91
  const names = collectTypeReferenceNames(typeAnnotation);
92
92
  return names.some((n) => firestoreTypeNames.has(n));
93
93
  }
94
+ /**
95
+ * True for the assertion wrappers that leave the underlying expression intact:
96
+ * `x as T` and `x satisfies T`.
97
+ */
98
+ function isCastExpression(node) {
99
+ return (node.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
100
+ node.type ===
101
+ utils_1.AST_NODE_TYPES
102
+ .TSSatisfiesExpression);
103
+ }
94
104
  /**
95
105
  * Returns the inner expression, unwrapping TSAsExpression / TSSatisfiesExpression
96
106
  * chains, so we can inspect what lies under a cast.
97
107
  */
98
108
  function unwrapCast(node) {
99
109
  let current = node;
100
- while (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
101
- current.type ===
102
- utils_1.AST_NODE_TYPES
103
- .TSSatisfiesExpression) {
110
+ while (isCastExpression(current)) {
104
111
  current = current.expression;
105
112
  }
106
113
  return current;
107
114
  }
115
+ /**
116
+ * True when some `as` cast in the wrapper chain targets a Firestore type. Those
117
+ * casts are the TSAsExpression visitor's own entry point, so the annotation- and
118
+ * return-type-driven visitors must stand down on them or the same `new Date()`
119
+ * is reported twice.
120
+ */
121
+ function castChainTargetsFirestoreType(node, firestoreTypeNames) {
122
+ let current = node;
123
+ while (isCastExpression(current)) {
124
+ const cast = current;
125
+ if (cast.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
126
+ typeAnnotationReferencesFirestoreType(cast.typeAnnotation, firestoreTypeNames)) {
127
+ return true;
128
+ }
129
+ current = cast.expression;
130
+ }
131
+ return false;
132
+ }
133
+ /**
134
+ * Resolves an expression in a Firestore-typed position to the object literal it
135
+ * ultimately denotes, seeing through `as const` and other assertion wrappers.
136
+ * A wrapper is a syntactic no-op — `{ createdAt: new Date() } as const` still
137
+ * stamps the document with the client clock — so it must not hide the object.
138
+ * Returns null when the chain is already owned by the TSAsExpression visitor.
139
+ */
140
+ function resolveObjectLiteral(node, firestoreTypeNames) {
141
+ if (!node)
142
+ return null;
143
+ if (castChainTargetsFirestoreType(node, firestoreTypeNames))
144
+ return null;
145
+ const inner = unwrapCast(node);
146
+ return inner.type === utils_1.AST_NODE_TYPES.ObjectExpression ? inner : null;
147
+ }
108
148
  /**
109
149
  * Checks whether an expression is `new Date(...)` (possibly wrapped in casts).
110
150
  */
@@ -343,19 +383,22 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
343
383
  VariableDeclarator(node) {
344
384
  if (firestoreTypeNames.size === 0)
345
385
  return;
346
- // Pattern: const x: FirestoreType = { ... }
386
+ // Pattern: const x: FirestoreType = { ... }, including when the literal
387
+ // is wrapped in `as const` or another assertion.
347
388
  const typeAnnotation = node.id.typeAnnotation?.typeAnnotation;
348
- if (typeAnnotation &&
349
- typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeNames) &&
350
- node.init &&
351
- node.init.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
352
- // Exempt local render seeds handed to React state, never written to
353
- // Firestore — being typed as a Firestore doc is not a write.
354
- if (context.getDeclaredVariables(node).some(isLocalRenderSeedVariable)) {
355
- return;
356
- }
357
- reportNewDatesInObject(node.init, context);
389
+ if (!typeAnnotation ||
390
+ !typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeNames)) {
391
+ return;
358
392
  }
393
+ const object = resolveObjectLiteral(node.init, firestoreTypeNames);
394
+ if (!object)
395
+ return;
396
+ // Exempt local render seeds handed to React state, never written to
397
+ // Firestore — being typed as a Firestore doc is not a write.
398
+ if (context.getDeclaredVariables(node).some(isLocalRenderSeedVariable)) {
399
+ return;
400
+ }
401
+ reportNewDatesInObject(object, context);
359
402
  },
360
403
  // Pattern: { ... } as FirestoreType or { ... } satisfies FirestoreType
361
404
  TSAsExpression(node) {
@@ -384,21 +427,24 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
384
427
  ArrowFunctionExpression(node) {
385
428
  if (firestoreTypeNames.size === 0)
386
429
  return;
387
- if (node.body.type !== utils_1.AST_NODE_TYPES.ObjectExpression)
430
+ if (node.body.type === utils_1.AST_NODE_TYPES.BlockStatement)
388
431
  return;
389
432
  const returnType = node.returnType?.typeAnnotation;
390
- if (returnType &&
391
- typeAnnotationReferencesFirestoreType(returnType, firestoreTypeNames)) {
392
- reportNewDatesInObject(node.body, context);
433
+ if (!returnType ||
434
+ !typeAnnotationReferencesFirestoreType(returnType, firestoreTypeNames)) {
435
+ return;
436
+ }
437
+ const object = resolveObjectLiteral(node.body, firestoreTypeNames);
438
+ if (object) {
439
+ reportNewDatesInObject(object, context);
393
440
  }
394
441
  },
395
442
  // Return statements in functions with explicit Firestore return type annotation
396
443
  ReturnStatement(node) {
397
444
  if (firestoreTypeNames.size === 0)
398
445
  return;
399
- if (!node.argument)
400
- return;
401
- if (node.argument.type !== utils_1.AST_NODE_TYPES.ObjectExpression)
446
+ const object = resolveObjectLiteral(node.argument, firestoreTypeNames);
447
+ if (!object)
402
448
  return;
403
449
  // Walk up to find the enclosing function and check its return type
404
450
  let ancestor = node.parent;
@@ -409,7 +455,7 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
409
455
  const returnType = ancestor.returnType?.typeAnnotation;
410
456
  if (returnType &&
411
457
  typeAnnotationReferencesFirestoreType(returnType, firestoreTypeNames)) {
412
- reportNewDatesInObject(node.argument, context);
458
+ reportNewDatesInObject(object, context);
413
459
  }
414
460
  break;
415
461
  }
@@ -0,0 +1,147 @@
1
+ import { Linter } from 'eslint';
2
+ import { HarvestResult } from './harvestRuleTesterCases';
3
+ /**
4
+ * The one harvest a module registry gets.
5
+ *
6
+ * A second `harvestRuleTesterCases()` call in the same registry returns ZERO
7
+ * suites: the suite files are already in the require cache, so requiring them
8
+ * again re-executes nothing and their `run` calls never fire, while
9
+ * `filesLoaded` still counts every file. A guard that harvests twice therefore
10
+ * runs its second corpus over nothing and reports a clean sweep. Every consumer
11
+ * — including one that wants both the raw suites and the adapted corpus below —
12
+ * goes through this.
13
+ */
14
+ export declare function harvestOnce(): HarvestResult;
15
+ /**
16
+ * Which array a snippet came out of. A fixer must converge, type-check and
17
+ * resolve its references identically whichever it is, so the bucket is carried
18
+ * for reporting rather than for filtering — except that an `output` is by
19
+ * construction an ALREADY-FIXED state, which makes it the cheapest available
20
+ * probe of "does the fixer fire again on its own result".
21
+ */
22
+ export type FixtureBucket = 'valid' | 'invalid' | 'output';
23
+ export type FixtureCase = {
24
+ code: string;
25
+ /** Only when the case declares one; a guard supplies its own default. */
26
+ filename?: string;
27
+ options?: readonly unknown[];
28
+ parserOptions?: Record<string, unknown>;
29
+ /** Which shared tester declared it, which fixes the parser. */
30
+ tester: string;
31
+ /** Declaring suite file, so a finding is reproducible by hand. */
32
+ origin: string;
33
+ bucket: FixtureBucket;
34
+ };
35
+ export type FixtureCorpus = {
36
+ byRule: Map<string, FixtureCase[]>;
37
+ /** Suites whose rule object is not in the plugin's map, `file::name`. */
38
+ suitesDropped: string[];
39
+ /** Suites skipped for declaring under a non-TypeScript tester. */
40
+ suitesNonTs: string[];
41
+ suitesUsed: number;
42
+ totalCases: number;
43
+ /** Non-vacuity accounting: a silent drop here would fake a clean sweep. */
44
+ filesLoaded: number;
45
+ failures: string[];
46
+ };
47
+ /**
48
+ * `ruleTesterJson` and `ruleTesterMarkdown` parse a different language, so their
49
+ * fixtures cannot be linted by the TypeScript parser these guards configure.
50
+ */
51
+ export declare const TS_TESTERS: Set<string>;
52
+ /**
53
+ * Rules that ask the checker a question. Under a bare `Linter` they have no
54
+ * program, so they report nothing and would manufacture a false clean rather
55
+ * than a finding — a guard therefore has to be able to say so out loud when one
56
+ * of them contributes no probe, instead of filing it under "no trigger".
57
+ *
58
+ * Read from the rule sources rather than `String(rule.create)`, since
59
+ * `createRule` wraps `create` and stringifying it matches nothing.
60
+ */
61
+ export declare const typeAwareRuleNames: Set<string>;
62
+ /**
63
+ * Rule name resolved by OBJECT IDENTITY, never by the display name `run`
64
+ * received: ~100 of the ~310 suites pass a name that is not a rule name
65
+ * (`requireMemo`, `prefer-next-dynamic (JSX scenarios)`), and name-keyed
66
+ * matching silently drops every case they declare. Identity holds because the
67
+ * suites and `../index` resolve to the same module instance under jest.
68
+ */
69
+ export declare const ruleNameByIdentity: Map<unknown, string>;
70
+ /**
71
+ * The filename a case is probed under when it declares none.
72
+ *
73
+ * `RuleTester` passes `undefined` in that situation, which ESLint renders as
74
+ * `<input>` — a name with no extension, under which every path-gated rule is
75
+ * silent and contributes nothing. A bare `file.ts`/`react.tsx` is the smallest
76
+ * departure that keeps those rules reachable, and it matches the extension the
77
+ * fixture's own tester implies.
78
+ */
79
+ export declare const defaultFilenameFor: (testCase: FixtureCase) => string;
80
+ /**
81
+ * Second-chance filenames, used ONLY for a rule that produced no probe at all
82
+ * under the authentic one.
83
+ *
84
+ * Probing every case under every one of these is what the text-harvest guards
85
+ * did, and it is 3.5x the work for zero extra rules covered (measured: 8,433
86
+ * fix pairs over 81 rules with the fan-out, 2,652 over the same 81 without).
87
+ * Spending that budget on the pairs a cap would otherwise drop is worth more
88
+ * than re-probing the same snippet under a path its author never wrote — but a
89
+ * rule that would otherwise be UNPROBED is the one case where the fan-out buys
90
+ * something, so it is kept for exactly that.
91
+ */
92
+ export declare const FALLBACK_FILENAMES: string[];
93
+ /**
94
+ * The parser options a case is probed under: the harness's own, overridden by
95
+ * whatever the fixture declared, with `jsx` merged rather than replaced so a
96
+ * case that declares an unrelated `ecmaFeatures` does not silently turn JSX
97
+ * parsing off for itself.
98
+ */
99
+ export declare const parserOptionsFor: (testCase: FixtureCase) => {
100
+ ecmaFeatures: {
101
+ jsx: boolean;
102
+ };
103
+ ecmaVersion: number;
104
+ sourceType: string;
105
+ };
106
+ /**
107
+ * Rules that offer suggestions. `--fix` never applies one, so a guard driving
108
+ * `verifyAndFix` (or reading a fixture's `output`) probes the fix channel
109
+ * exclusively and every transform these rules emit stays unexamined (#1733).
110
+ */
111
+ export declare const suggestionRuleNames: string[];
112
+ export type SuggestionEdit = {
113
+ /** Stable identity within one lint of one source, `<report>:<suggestion>`. */
114
+ slot: string;
115
+ /** Which rule offered it, so a multi-rule lint still names the culprit. */
116
+ ruleId: string;
117
+ messageId: string;
118
+ desc: string;
119
+ /** The source with THIS suggestion applied, and nothing else. */
120
+ output: string;
121
+ };
122
+ /**
123
+ * Every state a user can reach by accepting ONE suggestion from `messages`.
124
+ *
125
+ * Three semantics are load-bearing, and each is a way the probe would otherwise
126
+ * judge a suggestion against a state nobody can produce:
127
+ * - ALONE. Each edit lands on the untouched source. Accepting two suggestions
128
+ * from one report is not reachable — the first rewrite invalidates the
129
+ * second's ranges — and neither is feeding the result back through a fix
130
+ * loop, which is what `verifyAndFix` would do.
131
+ * - ONE STEP. The output is a single accepted suggestion, not a fixed point.
132
+ * A suggestion is an offer, so the contract is progress, not closure.
133
+ * - A `fix()` returning `null` is a DECLINE, not a defect. ESLint drops those
134
+ * silently before the message is built, so they never arrive here; an edit
135
+ * that changes nothing is discarded for the same reason.
136
+ */
137
+ export declare function suggestionEditsOf(code: string, messages: readonly Linter.LintMessage[],
138
+ /** Restricts to one rule; omitted, every reporting rule contributes. */
139
+ ruleId?: string): SuggestionEdit[];
140
+ /** A case's options must reach the FIX pass, or the finding is a fabrication. */
141
+ export declare const severityWithOptions: (testCase: FixtureCase) => "error" | unknown[];
142
+ /**
143
+ * Harvested once per process. Loading ~270 suites is the dominant cost of every
144
+ * consumer, and a second call would pay it again for a corpus that cannot have
145
+ * changed.
146
+ */
147
+ export declare function harvestFixtureCorpus(): FixtureCorpus;
@@ -0,0 +1,311 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.harvestFixtureCorpus = exports.severityWithOptions = exports.suggestionEditsOf = exports.suggestionRuleNames = exports.parserOptionsFor = exports.FALLBACK_FILENAMES = exports.defaultFilenameFor = exports.ruleNameByIdentity = exports.typeAwareRuleNames = exports.TS_TESTERS = exports.harvestOnce = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const harvestRuleTesterCases_1 = require("./harvestRuleTesterCases");
10
+ /**
11
+ * The fixture corpus every fixer guard probes, keyed by RULE NAME.
12
+ *
13
+ * The guards used to build this by text-parsing `src/tests/<rule>.test.ts` and
14
+ * keeping the static string literals, which loses two things at once. A case
15
+ * assembled by interpolation is invisible — `no-usememo-for-pass-by-value`
16
+ * prepends a shared `typedPrelude` to all 64 of its cases and yielded exactly
17
+ * ONE snippet — and a snippet that does survive arrives stripped of the
18
+ * `filename` and `options` it was written for, so it is probed under a
19
+ * configuration its author never wrote (#1732).
20
+ *
21
+ * `harvestRuleTesterCases` loads each suite with `run` shadowed, so the real
22
+ * case objects are captured: interpolated code, options, filename and
23
+ * parserOptions together. This module is the adapter between that raw capture
24
+ * and what a `Linter`-based guard needs.
25
+ */
26
+ /* eslint-disable @typescript-eslint/no-var-requires */
27
+ const plugin = require('../index');
28
+ /* eslint-enable @typescript-eslint/no-var-requires */
29
+ const RULES_DIR = path_1.default.join(__dirname, '..', 'rules');
30
+ let rawHarvest = null;
31
+ /**
32
+ * The one harvest a module registry gets.
33
+ *
34
+ * A second `harvestRuleTesterCases()` call in the same registry returns ZERO
35
+ * suites: the suite files are already in the require cache, so requiring them
36
+ * again re-executes nothing and their `run` calls never fire, while
37
+ * `filesLoaded` still counts every file. A guard that harvests twice therefore
38
+ * runs its second corpus over nothing and reports a clean sweep. Every consumer
39
+ * — including one that wants both the raw suites and the adapted corpus below —
40
+ * goes through this.
41
+ */
42
+ function harvestOnce() {
43
+ if (!rawHarvest)
44
+ rawHarvest = (0, harvestRuleTesterCases_1.harvestRuleTesterCases)();
45
+ return rawHarvest;
46
+ }
47
+ exports.harvestOnce = harvestOnce;
48
+ /**
49
+ * `ruleTesterJson` and `ruleTesterMarkdown` parse a different language, so their
50
+ * fixtures cannot be linted by the TypeScript parser these guards configure.
51
+ */
52
+ exports.TS_TESTERS = new Set(['ruleTesterTs', 'ruleTesterJsx']);
53
+ /**
54
+ * Parser options that build a TypeScript PROGRAM. A guard driving a bare
55
+ * `Linter` has none, and honouring these would make it spend seconds per case
56
+ * constructing one from the repo's own tsconfig — or throw, since a fixture's
57
+ * relative `project` is resolved against whatever cwd the runner happens to
58
+ * have. Stripped rather than dropped: the snippet still exercises every
59
+ * syntactic path of the rule.
60
+ */
61
+ const PROGRAM_OPTIONS = new Set([
62
+ 'project',
63
+ 'projectService',
64
+ 'programs',
65
+ 'tsconfigRootDir',
66
+ 'EXPERIMENTAL_useProjectService',
67
+ ]);
68
+ const withoutProgramOptions = (raw) => {
69
+ if (!raw || typeof raw !== 'object')
70
+ return undefined;
71
+ const out = {};
72
+ for (const [key, value] of Object.entries(raw)) {
73
+ if (PROGRAM_OPTIONS.has(key))
74
+ continue;
75
+ out[key] = value;
76
+ }
77
+ return Object.keys(out).length ? out : undefined;
78
+ };
79
+ /**
80
+ * Rules that ask the checker a question. Under a bare `Linter` they have no
81
+ * program, so they report nothing and would manufacture a false clean rather
82
+ * than a finding — a guard therefore has to be able to say so out loud when one
83
+ * of them contributes no probe, instead of filing it under "no trigger".
84
+ *
85
+ * Read from the rule sources rather than `String(rule.create)`, since
86
+ * `createRule` wraps `create` and stringifying it matches nothing.
87
+ */
88
+ exports.typeAwareRuleNames = new Set(fs_1.default
89
+ .readdirSync(RULES_DIR)
90
+ .filter((file) => file.endsWith('.ts'))
91
+ .filter((file) => /getParserServices|getTypeChecker/.test(fs_1.default.readFileSync(path_1.default.join(RULES_DIR, file), 'utf8')))
92
+ .map((file) => path_1.default.basename(file, '.ts')));
93
+ /**
94
+ * Rule name resolved by OBJECT IDENTITY, never by the display name `run`
95
+ * received: ~100 of the ~310 suites pass a name that is not a rule name
96
+ * (`requireMemo`, `prefer-next-dynamic (JSX scenarios)`), and name-keyed
97
+ * matching silently drops every case they declare. Identity holds because the
98
+ * suites and `../index` resolve to the same module instance under jest.
99
+ */
100
+ exports.ruleNameByIdentity = new Map(Object.entries(plugin.rules).map(([name, rule]) => [rule, name]));
101
+ /**
102
+ * The filename a case is probed under when it declares none.
103
+ *
104
+ * `RuleTester` passes `undefined` in that situation, which ESLint renders as
105
+ * `<input>` — a name with no extension, under which every path-gated rule is
106
+ * silent and contributes nothing. A bare `file.ts`/`react.tsx` is the smallest
107
+ * departure that keeps those rules reachable, and it matches the extension the
108
+ * fixture's own tester implies.
109
+ */
110
+ const defaultFilenameFor = (testCase) => testCase.filename ??
111
+ (testCase.tester === 'ruleTesterJsx' ? 'react.tsx' : 'file.ts');
112
+ exports.defaultFilenameFor = defaultFilenameFor;
113
+ /**
114
+ * Second-chance filenames, used ONLY for a rule that produced no probe at all
115
+ * under the authentic one.
116
+ *
117
+ * Probing every case under every one of these is what the text-harvest guards
118
+ * did, and it is 3.5x the work for zero extra rules covered (measured: 8,433
119
+ * fix pairs over 81 rules with the fan-out, 2,652 over the same 81 without).
120
+ * Spending that budget on the pairs a cap would otherwise drop is worth more
121
+ * than re-probing the same snippet under a path its author never wrote — but a
122
+ * rule that would otherwise be UNPROBED is the one case where the fan-out buys
123
+ * something, so it is kept for exactly that.
124
+ */
125
+ exports.FALLBACK_FILENAMES = [
126
+ '/repo/src/components/Widget.tsx',
127
+ '/repo/src/util/helper.ts',
128
+ '/repo/src/util/helper.test.ts',
129
+ '/repo/src/__tests__/helper.test.ts',
130
+ '/repo/functions/src/util/helper.test.ts',
131
+ '/repo/functions/src/callable/handler.ts',
132
+ '/repo/src/pages/index.tsx',
133
+ ];
134
+ /**
135
+ * The parser options a case is probed under: the harness's own, overridden by
136
+ * whatever the fixture declared, with `jsx` merged rather than replaced so a
137
+ * case that declares an unrelated `ecmaFeatures` does not silently turn JSX
138
+ * parsing off for itself.
139
+ */
140
+ const parserOptionsFor = (testCase) => {
141
+ const declared = testCase.parserOptions || {};
142
+ const features = (declared.ecmaFeatures || {});
143
+ return {
144
+ ecmaVersion: 2022,
145
+ sourceType: 'module',
146
+ ...declared,
147
+ ecmaFeatures: { jsx: true, ...features },
148
+ };
149
+ };
150
+ exports.parserOptionsFor = parserOptionsFor;
151
+ /**
152
+ * Rules that offer suggestions. `--fix` never applies one, so a guard driving
153
+ * `verifyAndFix` (or reading a fixture's `output`) probes the fix channel
154
+ * exclusively and every transform these rules emit stays unexamined (#1733).
155
+ */
156
+ exports.suggestionRuleNames = Object.entries(plugin.rules)
157
+ .filter(([, rule]) => rule?.meta?.hasSuggestions)
158
+ .map(([name]) => name)
159
+ .sort();
160
+ const applyEdit = (text, fix) => text.slice(0, fix.range[0]) + fix.text + text.slice(fix.range[1]);
161
+ /**
162
+ * Every state a user can reach by accepting ONE suggestion from `messages`.
163
+ *
164
+ * Three semantics are load-bearing, and each is a way the probe would otherwise
165
+ * judge a suggestion against a state nobody can produce:
166
+ * - ALONE. Each edit lands on the untouched source. Accepting two suggestions
167
+ * from one report is not reachable — the first rewrite invalidates the
168
+ * second's ranges — and neither is feeding the result back through a fix
169
+ * loop, which is what `verifyAndFix` would do.
170
+ * - ONE STEP. The output is a single accepted suggestion, not a fixed point.
171
+ * A suggestion is an offer, so the contract is progress, not closure.
172
+ * - A `fix()` returning `null` is a DECLINE, not a defect. ESLint drops those
173
+ * silently before the message is built, so they never arrive here; an edit
174
+ * that changes nothing is discarded for the same reason.
175
+ */
176
+ function suggestionEditsOf(code, messages,
177
+ /** Restricts to one rule; omitted, every reporting rule contributes. */
178
+ ruleId) {
179
+ const edits = [];
180
+ messages.forEach((message, messageIndex) => {
181
+ if (ruleId && message.ruleId !== ruleId)
182
+ return;
183
+ (message.suggestions || []).forEach((suggestion, suggestionIndex) => {
184
+ if (!suggestion.fix)
185
+ return;
186
+ const output = applyEdit(code, suggestion.fix);
187
+ if (output === code)
188
+ return;
189
+ edits.push({
190
+ slot: `${messageIndex}:${suggestionIndex}`,
191
+ ruleId: message.ruleId || '',
192
+ messageId: message.messageId || message.message,
193
+ desc: suggestion.desc,
194
+ output,
195
+ });
196
+ });
197
+ });
198
+ return edits;
199
+ }
200
+ exports.suggestionEditsOf = suggestionEditsOf;
201
+ /** A case's options must reach the FIX pass, or the finding is a fabrication. */
202
+ const severityWithOptions = (testCase) => testCase.options && testCase.options.length
203
+ ? ['error', ...testCase.options]
204
+ : 'error';
205
+ exports.severityWithOptions = severityWithOptions;
206
+ /** Already-fixed states a case declares: its `output`, and each suggestion's. */
207
+ const outputsOf = (testCase) => {
208
+ const outputs = [];
209
+ if (typeof testCase.output === 'string')
210
+ outputs.push(testCase.output);
211
+ if (!Array.isArray(testCase.errors))
212
+ return outputs;
213
+ for (const error of testCase.errors) {
214
+ const suggestions = error?.suggestions;
215
+ if (!Array.isArray(suggestions))
216
+ continue;
217
+ for (const suggestion of suggestions) {
218
+ const output = suggestion?.output;
219
+ if (typeof output === 'string')
220
+ outputs.push(output);
221
+ }
222
+ }
223
+ return outputs;
224
+ };
225
+ let cached = null;
226
+ /**
227
+ * Harvested once per process. Loading ~270 suites is the dominant cost of every
228
+ * consumer, and a second call would pay it again for a corpus that cannot have
229
+ * changed.
230
+ */
231
+ function harvestFixtureCorpus() {
232
+ if (cached)
233
+ return cached;
234
+ const harvested = harvestOnce();
235
+ const byRule = new Map();
236
+ const suitesDropped = [];
237
+ const suitesNonTs = [];
238
+ let suitesUsed = 0;
239
+ let totalCases = 0;
240
+ for (const suite of harvested.suites) {
241
+ const name = exports.ruleNameByIdentity.get(suite.rule);
242
+ if (!name) {
243
+ suitesDropped.push(`${suite.file}::${suite.name}`);
244
+ continue;
245
+ }
246
+ if (!exports.TS_TESTERS.has(suite.tester)) {
247
+ suitesNonTs.push(`${suite.file}::${suite.name}`);
248
+ continue;
249
+ }
250
+ suitesUsed++;
251
+ const cases = byRule.get(name) || [];
252
+ /**
253
+ * Deduped on the whole configuration, not on the code: the same snippet
254
+ * probed under different options is a different probe, and several suites
255
+ * declare exactly that pair to pin an option's effect.
256
+ */
257
+ const keyOf = (testCase) => JSON.stringify([
258
+ testCase.code,
259
+ testCase.options,
260
+ testCase.filename,
261
+ testCase.parserOptions,
262
+ ]);
263
+ const seen = new Set(cases.map(keyOf));
264
+ const push = (code, raw, bucket, parserOptions) => {
265
+ const testCase = {
266
+ code,
267
+ filename: typeof raw.filename === 'string' ? raw.filename : undefined,
268
+ options: Array.isArray(raw.options)
269
+ ? raw.options
270
+ : undefined,
271
+ parserOptions,
272
+ tester: suite.tester,
273
+ origin: suite.file,
274
+ bucket,
275
+ };
276
+ const key = keyOf(testCase);
277
+ if (seen.has(key))
278
+ return;
279
+ seen.add(key);
280
+ cases.push(testCase);
281
+ totalCases++;
282
+ };
283
+ const collect = (raw, bucket) => {
284
+ const declared = (typeof raw === 'string' ? { code: raw } : raw);
285
+ if (!declared || typeof declared.code !== 'string')
286
+ return;
287
+ const parserOptions = withoutProgramOptions(declared.parserOptions);
288
+ push(declared.code, declared, bucket, parserOptions);
289
+ for (const output of outputsOf(declared)) {
290
+ push(output, declared, 'output', parserOptions);
291
+ }
292
+ };
293
+ for (const raw of suite.valid)
294
+ collect(raw, 'valid');
295
+ for (const raw of suite.invalid)
296
+ collect(raw, 'invalid');
297
+ byRule.set(name, cases);
298
+ }
299
+ cached = {
300
+ byRule,
301
+ suitesDropped,
302
+ suitesNonTs,
303
+ suitesUsed,
304
+ totalCases,
305
+ filesLoaded: harvested.filesLoaded,
306
+ failures: harvested.failures,
307
+ };
308
+ return cached;
309
+ }
310
+ exports.harvestFixtureCorpus = harvestFixtureCorpus;
311
+ //# sourceMappingURL=fixtureCorpus.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.108",
3
+ "version": "1.20.110",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,72 @@
1
1
  [
2
+ {
3
+ "version": "1.20.110",
4
+ "date": "2026-08-05T11:59:59.800Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-firestore-doc-ref-generic",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1730
11
+ ],
12
+ "summary": "drop the unearned requiresTypeChecking declaration (closes #1730)"
13
+ },
14
+ {
15
+ "name": "enforce-memoize-async",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1735
19
+ ],
20
+ "summary": "decline to decorate a method of a class expression (closes #1735)"
21
+ },
22
+ {
23
+ "name": "no-redundant-usecallback-wrapper",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1729
27
+ ],
28
+ "summary": "report provably memoized wrappers under default options (closes #1729)"
29
+ },
30
+ {
31
+ "name": "prefer-fragment-component",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1736
35
+ ],
36
+ "summary": "declare the disabled severity it actually ships (closes #1736)"
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "version": "1.20.109",
42
+ "date": "2026-08-05T07:42:30.150Z",
43
+ "rules": [
44
+ {
45
+ "name": "firestore-transaction-reads-before-writes",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1728
49
+ ],
50
+ "summary": "resolve a call-wrapped computed key (closes #1728)"
51
+ },
52
+ {
53
+ "name": "no-redundant-usecallback-wrapper",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1726
57
+ ],
58
+ "summary": "see the useLatestCallback spelling (closes #1726)"
59
+ },
60
+ {
61
+ "name": "require-server-timestamp-for-firestore-dates",
62
+ "changeType": "fix",
63
+ "issues": [
64
+ 1727
65
+ ],
66
+ "summary": "look through cast wrappers (closes #1727)"
67
+ }
68
+ ]
69
+ },
2
70
  {
3
71
  "version": "1.20.108",
4
72
  "date": "2026-08-05T05:55:32.346Z",