@blumintinc/eslint-plugin-blumint 1.20.109 → 1.20.111

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.109',
226
+ version: '1.20.111',
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: {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceGlobalConstants = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const shebang_1 = require("../utils/shebang");
6
7
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
8
  exports.enforceGlobalConstants = (0, createRule_1.createRule)({
8
9
  name: 'enforce-global-constants',
@@ -269,7 +270,10 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
269
270
  }
270
271
  else {
271
272
  const body = program.body;
272
- let insertPos = 0;
273
+ // A shebang has to stay at character 0 or the file stops parsing
274
+ // (TS18026), so it bounds the insertion the same way the
275
+ // directive prologue below does.
276
+ let insertPos = (0, shebang_1.afterShebang)(text);
273
277
  let afterDirectiveIdx = -1;
274
278
  for (let i = 0; i < body.length; i++) {
275
279
  const stmt = body[i];
@@ -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
@@ -4,6 +4,7 @@ exports.logicalTopToBottomGrouping = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const ASTHelpers_1 = require("../utils/ASTHelpers");
6
6
  const createRule_1 = require("../utils/createRule");
7
+ const shebang_1 = require("../utils/shebang");
7
8
  const TYPE_EXPRESSION_WRAPPERS = new Set([
8
9
  utils_1.AST_NODE_TYPES.TSAsExpression,
9
10
  utils_1.AST_NODE_TYPES.TSTypeAssertion,
@@ -730,7 +731,12 @@ function findEarliestSafeIndex(body, startIndex, dependencies, { allowHooks, sto
730
731
  * comments directly above it reads as its preamble.
731
732
  */
732
733
  function getLeadingComments(statement, sourceCode) {
733
- const comments = sourceCode.getCommentsBefore(statement);
734
+ // A shebang belongs to the file, not to the statement below it. Left in the
735
+ // preamble, relocating the first statement carries `#!` off character 0 and
736
+ // the output stops parsing.
737
+ const comments = sourceCode
738
+ .getCommentsBefore(statement)
739
+ .filter((comment) => !(0, shebang_1.isShebangComment)(sourceCode, comment));
734
740
  const ownLine = comments.findIndex((comment) => {
735
741
  const previous = sourceCode.getTokenBefore(comment, {
736
742
  includeComments: true,
@@ -3,8 +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");
6
7
  const LATEST_CALLBACK_MODULE = 'use-latest-callback';
7
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
+ }
8
77
  function isHookLikeName(name) {
9
78
  return name.startsWith('use');
10
79
  }
@@ -142,6 +211,63 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
142
211
  }
143
212
  return null;
144
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
+ };
145
271
  // Track identifiers coming from hook-like calls
146
272
  const hookReturnObjects = new Set(); // variables assigned to a hook call result (object or function)
147
273
  const hookReturnProps = new Set(); // properties destructured from a hook call result
@@ -214,7 +340,8 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
214
340
  unwrappedArg.type === utils_1.AST_NODE_TYPES.MemberExpression)) {
215
341
  if ((unwrappedArg.type === utils_1.AST_NODE_TYPES.Identifier &&
216
342
  (hookReturnProps.has(unwrappedArg.name) ||
217
- hookReturnObjects.has(unwrappedArg.name))) ||
343
+ hookReturnObjects.has(unwrappedArg.name) ||
344
+ isLocallyMemoizedCallback(unwrappedArg, node))) ||
218
345
  (unwrappedArg.type === utils_1.AST_NODE_TYPES.MemberExpression &&
219
346
  unwrappedArg.object.type === utils_1.AST_NODE_TYPES.Identifier &&
220
347
  hookReturnObjects.has(unwrappedArg.object.name))) {
@@ -259,7 +386,8 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
259
386
  isIdentifierOrMemberOn(callee, hookReturnObjects)) ||
260
387
  (callee &&
261
388
  callee.type === utils_1.AST_NODE_TYPES.Identifier &&
262
- hookReturnProps.has(callee.name))) {
389
+ (hookReturnProps.has(callee.name) ||
390
+ isLocallyMemoizedCallback(callee, node)))) {
263
391
  if (bodyExpr.arguments.length > 0) {
264
392
  // Passing any arguments: treat as non-redundant (avoid false positives)
265
393
  return;
@@ -316,7 +444,8 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
316
444
  const callee = unwrapChainExpression(expr.callee);
317
445
  const isHookProp = callee &&
318
446
  callee.type === utils_1.AST_NODE_TYPES.Identifier &&
319
- hookReturnProps.has(callee.name);
447
+ (hookReturnProps.has(callee.name) ||
448
+ isLocallyMemoizedCallback(callee, node));
320
449
  const isHookObjMember = callee &&
321
450
  callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
322
451
  callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -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: [],
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.verticallyGroupRelatedFunctions = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const ASTHelpers_1 = require("../utils/ASTHelpers");
6
+ const shebang_1 = require("../utils/shebang");
6
7
  const DEFAULT_OPTIONS = {
7
8
  exportPlacement: 'ignore',
8
9
  dependencyDirection: 'callers-first',
@@ -329,10 +330,15 @@ function getStatementRangeWithComments(statement, sourceCode, consumedComments,
329
330
  // below would drag an interleaved statement's own end-of-line comment along
330
331
  // when the following function is relocated. Only own-line comments count as
331
332
  // leading comments.
332
- const leadingCommentsOf = (target) => filterComments(sourceCode.getCommentsBefore(target) || []).filter((comment) => {
333
+ const leadingCommentsOf = (target) => filterComments(sourceCode.getCommentsBefore(target) || [])
334
+ .filter((comment) => {
333
335
  const tokenBefore = sourceCode.getTokenBefore(comment);
334
336
  return (!tokenBefore || tokenBefore.loc.end.line !== comment.loc.start.line);
335
- });
337
+ })
338
+ // A shebang belongs to the file, not to the statement below it. Left in
339
+ // the span, relocating the first statement carries `#!` off character 0
340
+ // and the output stops parsing.
341
+ .filter((comment) => !(0, shebang_1.isShebangComment)(sourceCode, comment));
336
342
  const commentsBefore = leadingCommentsOf(statement);
337
343
  const nextLeadingComments = nextStatement
338
344
  ? new Set(leadingCommentsOf(nextStatement))
@@ -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
@@ -0,0 +1,19 @@
1
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ /**
3
+ * A shebang is only a shebang at character 0. One byte in front of it and the
4
+ * file stops parsing outright (`TS18026: '#!' can only be used at the start of
5
+ * a file`) and stops being executable.
6
+ *
7
+ * ESLint hands it to rules as an ordinary leading comment of the first
8
+ * statement, which is what makes it easy to lose: a fixer that relocates a
9
+ * statement together with its leading comments carries the shebang into the
10
+ * middle of the file, and one that splices at offset 0 pushes it off line 1.
11
+ * `importInsertion` already encodes this for the rules that add an import;
12
+ * these are the same rule for the ones that reorder or hoist.
13
+ */
14
+ export declare function isShebangComment(sourceCode: Pick<TSESLint.SourceCode, 'text'>, comment: TSESTree.Comment): boolean;
15
+ /**
16
+ * The first offset at which text may be spliced without displacing a shebang.
17
+ * Zero for the overwhelmingly common file that has none.
18
+ */
19
+ export declare function afterShebang(text: string): number;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.afterShebang = exports.isShebangComment = void 0;
4
+ /**
5
+ * A shebang is only a shebang at character 0. One byte in front of it and the
6
+ * file stops parsing outright (`TS18026: '#!' can only be used at the start of
7
+ * a file`) and stops being executable.
8
+ *
9
+ * ESLint hands it to rules as an ordinary leading comment of the first
10
+ * statement, which is what makes it easy to lose: a fixer that relocates a
11
+ * statement together with its leading comments carries the shebang into the
12
+ * middle of the file, and one that splices at offset 0 pushes it off line 1.
13
+ * `importInsertion` already encodes this for the rules that add an import;
14
+ * these are the same rule for the ones that reorder or hoist.
15
+ */
16
+ function isShebangComment(sourceCode, comment) {
17
+ return comment.range[0] === 0 && sourceCode.text.startsWith('#!');
18
+ }
19
+ exports.isShebangComment = isShebangComment;
20
+ /**
21
+ * The first offset at which text may be spliced without displacing a shebang.
22
+ * Zero for the overwhelmingly common file that has none.
23
+ */
24
+ function afterShebang(text) {
25
+ if (!text.startsWith('#!')) {
26
+ return 0;
27
+ }
28
+ const lineEnd = text.indexOf('\n');
29
+ return lineEnd === -1 ? text.length : lineEnd + 1;
30
+ }
31
+ exports.afterShebang = afterShebang;
32
+ //# sourceMappingURL=shebang.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.109",
3
+ "version": "1.20.111",
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.111",
4
+ "date": "2026-08-05T13:37:20.101Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-global-constants",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1739
11
+ ],
12
+ "summary": "hoist below a shebang, not above it (closes #1739)"
13
+ },
14
+ {
15
+ "name": "logical-top-to-bottom-grouping",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1738
19
+ ],
20
+ "summary": "keep a shebang at character 0 when reordering (closes #1738)"
21
+ },
22
+ {
23
+ "name": "vertically-group-related-functions",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1737
27
+ ],
28
+ "summary": "keep a shebang at character 0 when reordering (closes #1737)"
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "version": "1.20.110",
34
+ "date": "2026-08-05T11:59:59.800Z",
35
+ "rules": [
36
+ {
37
+ "name": "enforce-firestore-doc-ref-generic",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1730
41
+ ],
42
+ "summary": "drop the unearned requiresTypeChecking declaration (closes #1730)"
43
+ },
44
+ {
45
+ "name": "enforce-memoize-async",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1735
49
+ ],
50
+ "summary": "decline to decorate a method of a class expression (closes #1735)"
51
+ },
52
+ {
53
+ "name": "no-redundant-usecallback-wrapper",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1729
57
+ ],
58
+ "summary": "report provably memoized wrappers under default options (closes #1729)"
59
+ },
60
+ {
61
+ "name": "prefer-fragment-component",
62
+ "changeType": "fix",
63
+ "issues": [
64
+ 1736
65
+ ],
66
+ "summary": "declare the disabled severity it actually ships (closes #1736)"
67
+ }
68
+ ]
69
+ },
2
70
  {
3
71
  "version": "1.20.109",
4
72
  "date": "2026-08-05T07:42:30.150Z",