@blumintinc/eslint-plugin-blumint 1.19.31 → 1.20.0

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
@@ -196,6 +196,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
196
196
  | [no-redundant-param-types](docs/rules/no-redundant-param-types.md) | Disallow redundant parameter type annotations | ✅ | | | 🔧 | | |
197
197
  | [no-redundant-this-params](docs/rules/no-redundant-this-params.md) | Disallow passing class instance members (this.foo) into class instance methods; access the member from this inside the method instead. | ✅ | | | | | |
198
198
  | [no-redundant-usecallback-wrapper](docs/rules/no-redundant-usecallback-wrapper.md) | Prevent wrapping already memoized/stable callbacks from hooks/contexts in an extra useCallback() | ✅ | | | 🔧 | | |
199
+ | [no-render-function-components](docs/rules/no-render-function-components.md) | Disallow declaring a local `render*` function that returns JSX and consuming it as a plain function (direct call or array-iteration callback); author it as a real component rendered with JSX instead | ✅ | | | | | |
199
200
  | [no-res-error-status-in-onrequest](docs/rules/no-res-error-status-in-onrequest.md) | Forbid sending 4xx/5xx Express responses inside onRequest handlers; throw structured HttpsError instances instead so the wrapper can format, log, and map errors consistently. | ✅ | | | | | |
200
201
  | [no-restricted-properties-fix](docs/rules/no-restricted-properties-fix.md) | Disallow certain properties on certain objects, with special handling for Object.keys() and Object.values() | | | | 🔧 | | |
201
202
  | [no-satisfies-in-frontend-bundle](docs/rules/no-satisfies-in-frontend-bundle.md) | Disallow the `satisfies` operator in files that reach the frontend webpack bundle (Next.js 12 SWC cannot parse it) | ✅ | | | | | |
package/lib/index.js CHANGED
@@ -196,6 +196,7 @@ const no_single_dismiss_dialog_button_1 = require("./rules/no-single-dismiss-dia
196
196
  const no_portal_inside_tooltip_1 = require("./rules/no-portal-inside-tooltip");
197
197
  const no_satisfies_in_frontend_bundle_1 = require("./rules/no-satisfies-in-frontend-bundle");
198
198
  const prefer_utility_function_own_file_1 = require("./rules/prefer-utility-function-own-file");
199
+ const no_render_function_components_1 = require("./rules/no-render-function-components");
199
200
  const NO_FRONTEND_IMPORTS_FROM_FUNCTIONS_MESSAGE = 'Backend Cloud Functions (.f.ts under functions/) must not import frontend modules from the repo root src/**. Frontend code can depend on browser-only APIs and bundling it into Cloud Functions breaks server execution; move shared logic into functions/src or a shared package.';
200
201
  function noFrontendImportsFromFunctionsPatterns(pattern) {
201
202
  return [
@@ -222,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
222
223
  module.exports = {
223
224
  meta: {
224
225
  name: '@blumintinc/eslint-plugin-blumint',
225
- version: '1.19.31',
226
+ version: '1.20.0',
226
227
  },
227
228
  parseOptions: {
228
229
  ecmaVersion: 2020,
@@ -424,6 +425,7 @@ module.exports = {
424
425
  '@blumintinc/blumint/no-portal-inside-tooltip': 'error',
425
426
  '@blumintinc/blumint/no-satisfies-in-frontend-bundle': 'error',
426
427
  '@blumintinc/blumint/prefer-utility-function-own-file': 'error',
428
+ '@blumintinc/blumint/no-render-function-components': 'error',
427
429
  },
428
430
  /**
429
431
  * Depth-specific overrides block only import strings that traverse to the
@@ -700,6 +702,7 @@ module.exports = {
700
702
  'no-portal-inside-tooltip': no_portal_inside_tooltip_1.noPortalInsideTooltip,
701
703
  'no-satisfies-in-frontend-bundle': no_satisfies_in_frontend_bundle_1.noSatisfiesInFrontendBundle,
702
704
  'prefer-utility-function-own-file': prefer_utility_function_own_file_1.preferUtilityFunctionOwnFile,
705
+ 'no-render-function-components': no_render_function_components_1.noRenderFunctionComponents,
703
706
  },
704
707
  };
705
708
  //# sourceMappingURL=index.js.map
@@ -961,6 +961,14 @@ function isDefinitelyNonBooleanExpression(node) {
961
961
  case utils_1.AST_NODE_TYPES.ConditionalExpression:
962
962
  return (isDefinitelyNonBooleanExpression(node.consequent) ||
963
963
  isDefinitelyNonBooleanExpression(node.alternate));
964
+ // Unwrap type-only expression wrappers (`'msg' as const`, `x satisfies T`,
965
+ // `x!`) so an asserted string/number literal is still recognized. Unwrapping
966
+ // can only expose a non-boolean literal, never mask a real boolean, so it
967
+ // cannot over-exempt a genuinely negatively-named boolean.
968
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
969
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
970
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
971
+ return isDefinitelyNonBooleanExpression(node.expression);
964
972
  default:
965
973
  return false;
966
974
  }
@@ -0,0 +1,8 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ type RuleOptions = {
3
+ renderPropNames?: string[];
4
+ allowNames?: string[];
5
+ };
6
+ type Options = [RuleOptions?];
7
+ export declare const noRenderFunctionComponents: TSESLint.RuleModule<"renderFunctionComponent", Options, TSESLint.RuleListener>;
8
+ export {};
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.noRenderFunctionComponents = void 0;
4
+ const utils_1 = require("@typescript-eslint/utils");
5
+ const createRule_1 = require("../utils/createRule");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ /**
8
+ * Slot names that legitimately consume a `render*` callback BY REFERENCE.
9
+ * These belong to external libraries (MUI DataGrid `renderCell`, Autocomplete
10
+ * `renderInput`/`renderOption`, Algolia widgets' `render`, etc.) that invoke the
11
+ * callback themselves. Passing a function into one of these slots is NOT the
12
+ * anti-pattern this rule targets, so those wirings are exempt. The list is
13
+ * always applied; user-provided `renderPropNames` are added to (not replaced),
14
+ * so custom library slots can be exempted without losing the defaults.
15
+ */
16
+ const DEFAULT_RENDER_PROP_NAMES = [
17
+ 'renderCell',
18
+ 'renderHeader',
19
+ 'renderValue',
20
+ 'renderInput',
21
+ 'renderOption',
22
+ 'renderTags',
23
+ 'renderGroup',
24
+ 'render',
25
+ ];
26
+ /**
27
+ * Array iteration methods that accept a callback. Passing a `render*` function
28
+ * BY REFERENCE into one of these (e.g. `items.map(renderItem)`) consumes it as a
29
+ * plain function, which is the anti-pattern: the returned JSX is inlined instead
30
+ * of mounting as its own component.
31
+ */
32
+ const ARRAY_ITERATION_METHODS = new Set([
33
+ 'map',
34
+ 'flatMap',
35
+ 'forEach',
36
+ 'reduce',
37
+ 'reduceRight',
38
+ 'filter',
39
+ 'find',
40
+ 'findIndex',
41
+ 'findLast',
42
+ 'findLastIndex',
43
+ 'some',
44
+ 'every',
45
+ ]);
46
+ // Literal `render` prefix followed by an uppercase letter. Deliberately excludes
47
+ // bare `render`, `renderer`/`rendered` (lowercase continuation), PascalCase
48
+ // `Render*` (require-memo's domain), and `useRender*` hooks (no-jsx-in-hooks').
49
+ const RENDER_FN_NAME = /^render[A-Z]/;
50
+ function isFunctionLikeInit(node) {
51
+ return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
52
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression);
53
+ }
54
+ function getJsxAttributeName(name) {
55
+ if (name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
56
+ return name.name;
57
+ }
58
+ return null;
59
+ }
60
+ exports.noRenderFunctionComponents = (0, createRule_1.createRule)({
61
+ name: 'no-render-function-components',
62
+ meta: {
63
+ type: 'problem',
64
+ docs: {
65
+ description: 'Disallow declaring a local `render*` function that returns JSX and consuming it as a plain function (direct call or array-iteration callback); author it as a real component rendered with JSX instead',
66
+ recommended: 'error',
67
+ },
68
+ schema: [
69
+ {
70
+ type: 'object',
71
+ properties: {
72
+ renderPropNames: {
73
+ type: 'array',
74
+ items: { type: 'string' },
75
+ },
76
+ allowNames: {
77
+ type: 'array',
78
+ items: { type: 'string' },
79
+ },
80
+ },
81
+ additionalProperties: false,
82
+ },
83
+ ],
84
+ messages: {
85
+ renderFunctionComponent: 'Local function "{{name}}" returns JSX and is consumed as a plain function (called directly or passed to an array method like .map). ' +
86
+ 'A function that takes inputs and returns JSX IS a React component, so it should be a PascalCase functional component rendered as JSX (e.g. <{{suggestion}} {...props} />), not invoked like a helper. ' +
87
+ 'Calling it directly forfeits React reconciliation and key semantics, isolated hook scope, a memoization boundary, and DevTools visibility — the elements are inlined into the parent instead of mounting as their own component. ' +
88
+ 'Extract "{{name}}" into a real component named "{{suggestion}}" and render it with JSX.',
89
+ },
90
+ },
91
+ defaultOptions: [{}],
92
+ create(context, [options]) {
93
+ const userRenderPropNames = options?.renderPropNames ?? [];
94
+ const renderPropNames = new Set([
95
+ ...DEFAULT_RENDER_PROP_NAMES,
96
+ ...userRenderPropNames,
97
+ ]);
98
+ const allowNamePatterns = (options?.allowNames ?? []).map((pattern) => new RegExp(pattern));
99
+ const candidates = [];
100
+ function isAllowed(name) {
101
+ return allowNamePatterns.some((pattern) => pattern.test(name));
102
+ }
103
+ function findVariable(node, name) {
104
+ const declared = ASTHelpers_1.ASTHelpers.getDeclaredVariables(context, node);
105
+ return declared.find((variable) => variable.name === name) ?? null;
106
+ }
107
+ // A plain function invocation: renderFoo(...) where the identifier is the callee.
108
+ function isDirectCall(id) {
109
+ const parent = id.parent;
110
+ return (!!parent &&
111
+ parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
112
+ parent.callee === id);
113
+ }
114
+ // A by-reference pass into an array-iteration method: arr.map(renderFoo).
115
+ function isArrayIterationPass(id) {
116
+ const parent = id.parent;
117
+ if (!parent || parent.type !== utils_1.AST_NODE_TYPES.CallExpression) {
118
+ return false;
119
+ }
120
+ if (!parent.arguments.includes(id)) {
121
+ return false;
122
+ }
123
+ const callee = parent.callee;
124
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
125
+ callee.computed ||
126
+ callee.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
127
+ return false;
128
+ }
129
+ return ARRAY_ITERATION_METHODS.has(callee.property.name);
130
+ }
131
+ // A by-reference pass into a render-prop slot: <X renderCell={renderFoo} />
132
+ // or { renderCell: renderFoo }. The exemption anchors on the slot name, not
133
+ // the function's own name.
134
+ function isRenderPropSlotReference(id) {
135
+ const parent = id.parent;
136
+ if (!parent) {
137
+ return false;
138
+ }
139
+ if (parent.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
140
+ const attribute = parent.parent;
141
+ if (attribute &&
142
+ attribute.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
143
+ attribute.value === parent) {
144
+ const attrName = getJsxAttributeName(attribute.name);
145
+ if (attrName && renderPropNames.has(attrName)) {
146
+ return true;
147
+ }
148
+ }
149
+ }
150
+ if (parent.type === utils_1.AST_NODE_TYPES.Property && parent.value === id) {
151
+ const key = parent.key;
152
+ let keyName = null;
153
+ if (!parent.computed && key.type === utils_1.AST_NODE_TYPES.Identifier) {
154
+ keyName = key.name;
155
+ }
156
+ else if (key.type === utils_1.AST_NODE_TYPES.Literal &&
157
+ typeof key.value === 'string') {
158
+ keyName = key.value;
159
+ }
160
+ if (keyName && renderPropNames.has(keyName)) {
161
+ return true;
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+ function isExportSpecifierReference(id) {
167
+ const parent = id.parent;
168
+ return !!parent && parent.type === utils_1.AST_NODE_TYPES.ExportSpecifier;
169
+ }
170
+ return {
171
+ VariableDeclarator(node) {
172
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
173
+ return;
174
+ }
175
+ const name = node.id.name;
176
+ if (!RENDER_FN_NAME.test(name) || isAllowed(name)) {
177
+ return;
178
+ }
179
+ if (!node.init) {
180
+ return;
181
+ }
182
+ const init = ASTHelpers_1.ASTHelpers.unwrapTSAssertions(node.init);
183
+ if (!isFunctionLikeInit(init)) {
184
+ return;
185
+ }
186
+ if (!ASTHelpers_1.ASTHelpers.returnsJSX(init, context)) {
187
+ return;
188
+ }
189
+ const declaration = node.parent;
190
+ const isExported = !!declaration &&
191
+ declaration.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
192
+ !!declaration.parent &&
193
+ (declaration.parent.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
194
+ declaration.parent.type ===
195
+ utils_1.AST_NODE_TYPES.ExportDefaultDeclaration);
196
+ candidates.push({
197
+ name,
198
+ reportNode: node.id,
199
+ variable: findVariable(node, name),
200
+ isExported,
201
+ });
202
+ },
203
+ FunctionDeclaration(node) {
204
+ if (!node.id) {
205
+ return;
206
+ }
207
+ const name = node.id.name;
208
+ if (!RENDER_FN_NAME.test(name) || isAllowed(name)) {
209
+ return;
210
+ }
211
+ if (!ASTHelpers_1.ASTHelpers.returnsJSX(node, context)) {
212
+ return;
213
+ }
214
+ const isExported = !!node.parent &&
215
+ (node.parent.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
216
+ node.parent.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration);
217
+ candidates.push({
218
+ name,
219
+ reportNode: node.id,
220
+ variable: findVariable(node, name),
221
+ isExported,
222
+ });
223
+ },
224
+ 'Program:exit'() {
225
+ for (const candidate of candidates) {
226
+ const references = candidate.variable
227
+ ? candidate.variable.references
228
+ : [];
229
+ let hasViolatingConsumption = false;
230
+ let isWiredToSlot = false;
231
+ let exportedViaSpecifier = false;
232
+ for (const reference of references) {
233
+ // Skip the declaration write and any reassignments — only reads are
234
+ // consumption sites.
235
+ if (typeof reference.isRead === 'function' && !reference.isRead()) {
236
+ continue;
237
+ }
238
+ const id = reference.identifier;
239
+ if (isDirectCall(id) || isArrayIterationPass(id)) {
240
+ hasViolatingConsumption = true;
241
+ continue;
242
+ }
243
+ if (isRenderPropSlotReference(id)) {
244
+ isWiredToSlot = true;
245
+ continue;
246
+ }
247
+ if (isExportSpecifierReference(id)) {
248
+ exportedViaSpecifier = true;
249
+ continue;
250
+ }
251
+ }
252
+ const isExported = candidate.isExported || exportedViaSpecifier;
253
+ const shouldFlag = hasViolatingConsumption || (isExported && !isWiredToSlot);
254
+ if (shouldFlag) {
255
+ context.report({
256
+ node: candidate.reportNode,
257
+ messageId: 'renderFunctionComponent',
258
+ data: {
259
+ name: candidate.name,
260
+ suggestion: candidate.name.slice('render'.length),
261
+ },
262
+ });
263
+ }
264
+ }
265
+ },
266
+ };
267
+ },
268
+ });
269
+ //# sourceMappingURL=no-render-function-components.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.19.31",
3
+ "version": "1.20.0",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.0",
4
+ "date": "2026-07-24T15:36:40.953Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-render-function-components",
8
+ "changeType": "feat",
9
+ "issues": [
10
+ 1345
11
+ ],
12
+ "summary": "flag render* functions returning JSX consumed as plain calls (closes #1345)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.19.32",
18
+ "date": "2026-07-24T00:36:05.744Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-positive-naming",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1344
25
+ ],
26
+ "summary": "recognize wrapped and annotated non-boolean validator returns"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.19.31",
4
32
  "date": "2026-07-24T00:26:32.378Z",