@blumintinc/eslint-plugin-blumint 1.20.36 → 1.20.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.36',
226
+ version: '1.20.38',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -22,21 +22,48 @@ exports.default = (0, createRule_1.createRule)({
22
22
  return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
23
23
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression);
24
24
  }
25
+ // Matches both the bare hook (`useMemo(...)`, including the generic form
26
+ // `useCallback<T>(...)`, whose callee is still an identifier) and the
27
+ // namespaced form (`React.useMemo(...)`).
28
+ function isHookCallee(callee, hookName) {
29
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
30
+ return callee.name === hookName;
31
+ }
32
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
33
+ !callee.computed &&
34
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
35
+ callee.property.name === hookName);
36
+ }
25
37
  function isInsideUseCallback(node) {
26
38
  let current = node.parent;
27
39
  while (current) {
28
- if (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
29
- const { callee } = current;
30
- const isDirectUseCallback = callee.type === utils_1.AST_NODE_TYPES.Identifier &&
31
- callee.name === 'useCallback';
32
- const isMemberUseCallback = callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
33
- !callee.computed &&
34
- callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
35
- callee.property.name === 'useCallback';
36
- if (isDirectUseCallback || isMemberUseCallback) {
37
- return true;
38
- }
40
+ if (current.type === utils_1.AST_NODE_TYPES.CallExpression &&
41
+ isHookCallee(current.callee, 'useCallback')) {
42
+ return true;
43
+ }
44
+ current = current.parent;
45
+ }
46
+ return false;
47
+ }
48
+ // A `useMemo` factory re-runs only when its dependencies change, so every
49
+ // value it produces — including JSX and the inline handlers nested in that
50
+ // JSX — is exactly as referentially stable as the memo itself. Demanding an
51
+ // extra `useCallback` there buys nothing.
52
+ //
53
+ // `useCallback` deliberately gets no such treatment: it memoizes a function
54
+ // that runs on every invocation, so the JSX it returns (and the inline
55
+ // functions in it) is rebuilt each call and stays worth reporting.
56
+ function isInsideUseMemoFactory(node) {
57
+ let child = node;
58
+ let current = node.parent;
59
+ while (current) {
60
+ if (current.type === utils_1.AST_NODE_TYPES.CallExpression &&
61
+ isHookCallee(current.callee, 'useMemo') &&
62
+ // Only the factory argument is memoized; the dependency array is not.
63
+ current.arguments[0] === child) {
64
+ return true;
39
65
  }
66
+ child = current;
40
67
  current = current.parent;
41
68
  }
42
69
  return false;
@@ -167,6 +194,10 @@ exports.default = (0, createRule_1.createRule)({
167
194
  node.value.type !== utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
168
195
  return;
169
196
  }
197
+ // Props of JSX built inside a useMemo factory inherit the memo's stability
198
+ if (isInsideUseMemoFactory(node)) {
199
+ return;
200
+ }
170
201
  const { expression } = node.value;
171
202
  // Skip if the prop is already wrapped in useCallback or useMemo
172
203
  if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.enforceUniqueCursorHeaders = exports.RULE_NAME = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const minimatch_1 = require("minimatch");
9
+ const utils_1 = require("@typescript-eslint/utils");
9
10
  const createRule_1 = require("../utils/createRule");
10
11
  const DEFAULT_OPTIONS = {
11
12
  requiredPatterns: ['**/*.{ts,tsx,js,jsx}'],
@@ -212,6 +213,62 @@ const analyzeHeaderGroups = (candidateGroups, options) => {
212
213
  });
213
214
  return { primaryHeader, duplicateGroups, splitHeaderGroups };
214
215
  };
216
+ /**
217
+ * A template made of nothing but comments is safe to prepend to any file;
218
+ * anything else (bare text, trailing code) would be inserted as source and can
219
+ * leave the file unparseable.
220
+ */
221
+ const isCommentOnly = (template) => template
222
+ .replace(/\/\*[\s\S]*?\*\//gu, '')
223
+ .replace(/\/\/[^\n]*/gu, '')
224
+ .trim().length === 0;
225
+ const toTemplateComment = (template, raw, offset) => {
226
+ const isBlock = raw.startsWith('/*');
227
+ const precedingText = template.slice(0, offset);
228
+ const startLine = precedingText.split('\n').length;
229
+ const startColumn = offset - (precedingText.lastIndexOf('\n') + 1);
230
+ const rawLines = raw.split('\n');
231
+ const lastLine = rawLines[rawLines.length - 1];
232
+ return {
233
+ type: isBlock ? utils_1.AST_TOKEN_TYPES.Block : utils_1.AST_TOKEN_TYPES.Line,
234
+ /** Header detection normalizes the raw comment value, so drop only the delimiters. */
235
+ value: isBlock ? raw.slice(2, -2) : raw.slice(2),
236
+ range: [offset, offset + raw.length],
237
+ loc: {
238
+ start: { line: startLine, column: startColumn },
239
+ end: {
240
+ line: startLine + rawLines.length - 1,
241
+ column: rawLines.length > 1 ? lastLine.length : startColumn + raw.length,
242
+ },
243
+ },
244
+ };
245
+ };
246
+ const parseTemplateComments = (template) => {
247
+ const commentPattern = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu;
248
+ const comments = [];
249
+ for (let match = commentPattern.exec(template); match !== null; match = commentPattern.exec(template)) {
250
+ comments.push(toTemplateComment(template, match[0], match.index));
251
+ }
252
+ return comments;
253
+ };
254
+ /**
255
+ * A fixer must remove its own trigger. Inserting a template the rule itself
256
+ * would not accept as a header leaves `missingHeader` reported, so ESLint's fix
257
+ * loop re-inserts the template on every pass until it hits the pass ceiling.
258
+ *
259
+ * Whether a template is acceptable is not a property of its text alone: the
260
+ * rule groups comments first and demands that a single group carry every tag,
261
+ * so `allowSplitHeaders` and block adjacency decide the answer. Running the
262
+ * template through the same grouping the rule applies to real files keeps the
263
+ * fixer convergent by construction instead of by a second approximation.
264
+ */
265
+ const canTemplateSatisfyRule = (template, options, excludedAtDirectives) => {
266
+ if (options.requiredTags.length === 0 || !isCommentOnly(template)) {
267
+ return false;
268
+ }
269
+ const candidateGroups = collectHeaderGroups(parseTemplateComments(template), options, excludedAtDirectives);
270
+ return (analyzeHeaderGroups(candidateGroups, options).primaryHeader !== undefined);
271
+ };
215
272
  const computeHeaderInsertion = (sourceText, headerTemplate) => {
216
273
  const headerText = buildHeaderInsertionText(headerTemplate);
217
274
  if (!sourceText.startsWith('#!')) {
@@ -225,8 +282,7 @@ const computeHeaderInsertion = (sourceText, headerTemplate) => {
225
282
  /** Insert after shebang line */
226
283
  return { index: shebangNewlineIndex + 1, text: headerText };
227
284
  };
228
- const reportMissingHeader = (context, fileName, sourceText, options) => {
229
- const template = options.headerTemplate;
285
+ const reportMissingHeader = (context, fileName, sourceText, options, template) => {
230
286
  context.report({
231
287
  loc: { line: 1, column: 0 },
232
288
  messageId: 'missingHeader',
@@ -331,6 +387,10 @@ exports.enforceUniqueCursorHeaders = (0, createRule_1.createRule)({
331
387
  const fileName = context.getFilename();
332
388
  const matchPath = fileName.split(path_1.default.sep).join('/');
333
389
  const excludedAtDirectives = new Set(options.excludedAtDirectives);
390
+ const fixableTemplate = options.headerTemplate !== null &&
391
+ canTemplateSatisfyRule(options.headerTemplate, options, excludedAtDirectives)
392
+ ? options.headerTemplate
393
+ : null;
334
394
  if (fileName === '<input>') {
335
395
  return {};
336
396
  }
@@ -353,7 +413,7 @@ exports.enforceUniqueCursorHeaders = (0, createRule_1.createRule)({
353
413
  const candidateGroups = collectHeaderGroups(topComments, options, excludedAtDirectives);
354
414
  const { primaryHeader, duplicateGroups, splitHeaderGroups } = analyzeHeaderGroups(candidateGroups, options);
355
415
  if (!primaryHeader) {
356
- reportMissingHeader(context, fileName, sourceText, options);
416
+ reportMissingHeader(context, fileName, sourceText, options, fixableTemplate);
357
417
  return;
358
418
  }
359
419
  reportDuplicates(context, duplicateGroups, sourceText);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.36",
3
+ "version": "1.20.38",
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.38",
4
+ "date": "2026-07-30T20:52:16.921Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-callback-memo",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1465
11
+ ],
12
+ "summary": "allow an inline callback inside a useMemo factory (closes #1465)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.37",
18
+ "date": "2026-07-30T18:07:02.175Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-unique-cursor-headers",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1461
25
+ ],
26
+ "summary": "only autofix a template that satisfies the rule (closes #1461)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.36",
4
32
  "date": "2026-07-30T17:05:52.383Z",