@blumintinc/eslint-plugin-blumint 1.18.0 → 1.18.1

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
@@ -218,7 +218,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
218
218
  module.exports = {
219
219
  meta: {
220
220
  name: '@blumintinc/eslint-plugin-blumint',
221
- version: '1.18.0',
221
+ version: '1.18.1',
222
222
  },
223
223
  parseOptions: {
224
224
  ecmaVersion: 2020,
@@ -142,6 +142,45 @@ module.exports = (0, createRule_1.createRule)({
142
142
  return false;
143
143
  });
144
144
  }
145
+ // An event-handler callback's return value is discarded: `void`, `undefined`,
146
+ // `never`, or a `Promise` thereof. Anything else is a value the caller
147
+ // consumes, which marks the prop as an accessor rather than a handler.
148
+ function returnsVoidLike(type) {
149
+ if (type.isUnion()) {
150
+ return type.types.every((member) => returnsVoidLike(member));
151
+ }
152
+ if (type.flags &
153
+ (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) {
154
+ return true;
155
+ }
156
+ // `Promise<void>` (async handlers) unwraps to its resolved type.
157
+ if (type.getSymbol()?.getName() === 'Promise') {
158
+ const typeArgs = checker.getTypeArguments(type);
159
+ if (typeArgs.length === 1) {
160
+ return returnsVoidLike(typeArgs[0]);
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+ // A function-typed prop is an accessor (getRowId, valueGetter, filterOptions,
166
+ // a per-item props deriver, ...) rather than an event handler when its call
167
+ // signature returns a value the component consumes instead of a discarded
168
+ // `void`. The `on` prefix signals event handlers, so accessors are exempt.
169
+ // The prop's declared (contextual) type is authoritative because the passed
170
+ // value's inferred return type can be wider than the prop contract.
171
+ function isValueAccessor(node) {
172
+ const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
173
+ const contextualType = checker.getContextualType(tsNode);
174
+ const type = contextualType ?? checker.getTypeAtLocation(tsNode);
175
+ const members = type.isUnion() ? type.types : [type];
176
+ const signatures = members.flatMap((member) => member.getCallSignatures());
177
+ if (signatures.length === 0) {
178
+ return false;
179
+ }
180
+ // Exempt only when every call signature returns a consumed value; a mix
181
+ // that includes a void-returning signature keeps the handler semantics.
182
+ return signatures.every((signature) => !returnsVoidLike(checker.getReturnTypeOfSignature(signature)));
183
+ }
145
184
  return {
146
185
  // Check JSX attributes for callback props
147
186
  JSXAttribute(node) {
@@ -190,6 +229,7 @@ module.exports = (0, createRule_1.createRule)({
190
229
  !propName.startsWith('on') &&
191
230
  !propName.startsWith('render') &&
192
231
  !acceptsNonFunctionValue(node.value.expression) &&
232
+ !isValueAccessor(node.value.expression) &&
193
233
  !isRenderFunction(node.value.expression) &&
194
234
  !isReactComponentType(node.value.expression)) {
195
235
  const eventName = propName.charAt(0).toUpperCase() + propName.slice(1);
@@ -851,6 +851,12 @@ const DIS_EXCEPTIONS = [
851
851
  'disputed',
852
852
  'disputes',
853
853
  'disputing',
854
+ // "disabled" is a single English adjective and the native HTML/React/MUI prop
855
+ // name; its "dis" is a bound morpheme, not a separable negation of "abled".
856
+ 'disable',
857
+ 'disabled',
858
+ 'disables',
859
+ 'disabling',
854
860
  ];
855
861
  // Words that contain negative prefixes but should be treated as valid
856
862
  const EXCEPTION_WORDS_SET = new Set([...IN_EXCEPTIONS, ...NO_EXCEPTIONS, ...UN_EXCEPTIONS, ...DIS_EXCEPTIONS].map((word) => word.toLowerCase()));
@@ -3,6 +3,7 @@ type Options = [
3
3
  typesDirectory?: string;
4
4
  excludePatterns?: string[];
5
5
  includePaths?: string[];
6
+ frontendCoupledImportPatterns?: string[];
6
7
  }
7
8
  ];
8
9
  export declare const enforceTypesDirectoryPlacement: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"typeOnlyFileOutsideTypesDir", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
@@ -9,6 +9,18 @@ const utils_1 = require("@typescript-eslint/utils");
9
9
  const minimatch_1 = require("minimatch");
10
10
  const createRule_1 = require("../utils/createRule");
11
11
  const DEFAULT_TYPES_DIRECTORY = 'functions/src/types';
12
+ // A type-only file that imports one of these frontend-only module families is
13
+ // frontend-scoped: it cannot be relocated under `functions/src/types/**`, since
14
+ // that directory is compiled by the backend tsconfig and the backend must not
15
+ // import from `src/` (frontend). Such files have no valid backend target and
16
+ // legitimately live under `src/`, so the rule exempts them. Matched against
17
+ // absolute specifiers directly and against relative specifiers after resolving
18
+ // them relative to the importing file (so backend files importing relatively —
19
+ // which resolve to `functions/src/**` — stay flagged).
20
+ const DEFAULT_FRONTEND_COUPLED_IMPORT_PATTERNS = [
21
+ 'src/components/**',
22
+ 'src/hooks/**',
23
+ ];
12
24
  const DEFAULT_EXCLUDE_PATTERNS = [
13
25
  '**/*.d.ts',
14
26
  '**/*.test.ts',
@@ -62,6 +74,57 @@ function suggestTargetPath(filePath, typesDirectory) {
62
74
  }
63
75
  return null;
64
76
  }
77
+ /**
78
+ * Extracts the repo-relative portion of a path starting at `functions/src/`
79
+ * (backend) or `src/` (frontend), so absolute filenames and resolved relative
80
+ * specifiers match the same globs the rest of the rule uses. Backend is checked
81
+ * first because a backend path also contains `src/`.
82
+ */
83
+ function toRepoRelative(filePath) {
84
+ const normalized = normalizePath(filePath);
85
+ const match = normalized.match(/(?:^|\/)(functions\/src\/.*|src\/.*)$/);
86
+ return match ? match[1] : normalized;
87
+ }
88
+ /**
89
+ * Resolves an import/export module specifier to a repo-relative path for glob
90
+ * matching. Relative specifiers are resolved against the importing file's
91
+ * directory (so `./DialogHeader` from `src/components/dialog/x.ts` becomes
92
+ * `src/components/dialog/DialogHeader`); bare/absolute specifiers are used as-is.
93
+ */
94
+ function resolveSpecifier(specifier, fromFile) {
95
+ if (specifier.startsWith('.')) {
96
+ const dir = path_1.default.posix.dirname(normalizePath(fromFile));
97
+ return toRepoRelative(path_1.default.posix.join(dir, specifier));
98
+ }
99
+ return toRepoRelative(specifier);
100
+ }
101
+ /**
102
+ * Returns the module specifier of a top-level import/export statement that
103
+ * carries a `from '...'` source, or null for statements that don't.
104
+ */
105
+ function importSourceOf(node) {
106
+ if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration ||
107
+ node.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
108
+ node.type === utils_1.AST_NODE_TYPES.ExportAllDeclaration) {
109
+ const source = node.source;
110
+ return source && typeof source.value === 'string' ? source.value : null;
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * Returns true when the file imports at least one frontend-coupled module,
116
+ * meaning it cannot be relocated under the backend types directory.
117
+ */
118
+ function isFrontendCoupled(body, fromFile, matchers) {
119
+ return body.some((stmt) => {
120
+ const specifier = importSourceOf(stmt);
121
+ if (specifier === null) {
122
+ return false;
123
+ }
124
+ const resolved = resolveSpecifier(specifier, fromFile);
125
+ return matchers.some((mm) => mm.match(resolved));
126
+ });
127
+ }
65
128
  /**
66
129
  * Returns true when the given top-level statement is "type-only":
67
130
  * - ImportDeclaration (imports alone are not runtime code)
@@ -194,6 +257,11 @@ exports.enforceTypesDirectoryPlacement = (0, createRule_1.createRule)({
194
257
  items: { type: 'string' },
195
258
  default: DEFAULT_INCLUDE_PATHS,
196
259
  },
260
+ frontendCoupledImportPatterns: {
261
+ type: 'array',
262
+ items: { type: 'string' },
263
+ default: DEFAULT_FRONTEND_COUPLED_IMPORT_PATTERNS,
264
+ },
197
265
  },
198
266
  additionalProperties: false,
199
267
  },
@@ -208,12 +276,15 @@ exports.enforceTypesDirectoryPlacement = (0, createRule_1.createRule)({
208
276
  typesDirectory: DEFAULT_TYPES_DIRECTORY,
209
277
  excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
210
278
  includePaths: DEFAULT_INCLUDE_PATHS,
279
+ frontendCoupledImportPatterns: DEFAULT_FRONTEND_COUPLED_IMPORT_PATTERNS,
211
280
  },
212
281
  ],
213
282
  create(context, [options]) {
214
283
  const typesDirectory = options.typesDirectory ?? DEFAULT_TYPES_DIRECTORY;
215
284
  const excludePatterns = options.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS;
216
285
  const includePaths = options.includePaths ?? DEFAULT_INCLUDE_PATHS;
286
+ const frontendCoupledImportPatterns = options.frontendCoupledImportPatterns ??
287
+ DEFAULT_FRONTEND_COUPLED_IMPORT_PATTERNS;
217
288
  const filename = context.getFilename();
218
289
  // Skip synthetic filenames used by RuleTester when no filename is provided.
219
290
  if (filename === '<input>' || filename === '<text>') {
@@ -251,9 +322,16 @@ exports.enforceTypesDirectoryPlacement = (0, createRule_1.createRule)({
251
322
  return {};
252
323
  }
253
324
  }
325
+ const frontendCoupledMatchers = frontendCoupledImportPatterns.map((pattern) => new minimatch_1.Minimatch(pattern, { dot: true }));
254
326
  return {
255
327
  Program(programNode) {
256
- if (!isTypeOnlyFile(programNode.body)) {
328
+ const body = programNode.body;
329
+ if (!isTypeOnlyFile(body)) {
330
+ return;
331
+ }
332
+ // A frontend-coupled type file has no valid target under the backend
333
+ // types directory, so it must not be flagged (issue #1263).
334
+ if (isFrontendCoupled(body, normalizedFilename, frontendCoupledMatchers)) {
257
335
  return;
258
336
  }
259
337
  const suggested = suggestTargetPath(normalizedFilename, typesDirectory);
@@ -3,6 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const utils_1 = require("@typescript-eslint/utils");
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const isUpperSnakeCase = (str) => /^[A-Z][A-Z0-9_]*$/.test(str);
6
+ // Next.js recognizes these export names by their literal identifier, so
7
+ // renaming them to UPPER_SNAKE_CASE silently breaks the framework contract
8
+ // (e.g. `export const config` controls the API-route body parser / runtime).
9
+ // Only the export name matters to Next.js, so the exemption is gated on the
10
+ // declaration being exported — a local, unexported `config` is safe to rename.
11
+ const NEXTJS_RESERVED_EXPORTS = new Set([
12
+ 'config',
13
+ 'getServerSideProps',
14
+ 'getStaticProps',
15
+ 'getStaticPaths',
16
+ 'getInitialProps',
17
+ 'middleware',
18
+ ]);
6
19
  exports.default = (0, createRule_1.createRule)({
7
20
  name: 'global-const-style',
8
21
  meta: {
@@ -185,6 +198,15 @@ exports.default = (0, createRule_1.createRule)({
185
198
  });
186
199
  }
187
200
  }
201
+ // Skip the rename for exported Next.js reserved export names. Their
202
+ // identifier is an external framework contract that cannot be
203
+ // statically verified as safe to rename, so autofixing the rename
204
+ // silently regresses behavior (Issue #1257). The `as const` check
205
+ // above still applies since it never touches the export name.
206
+ const isExported = node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
207
+ if (isExported && NEXTJS_RESERVED_EXPORTS.has(name)) {
208
+ return;
209
+ }
188
210
  // Check for UPPER_SNAKE_CASE
189
211
  if (!isUpperSnakeCase(name)) {
190
212
  const newName = name
@@ -339,7 +339,31 @@ exports.noHungarian = (0, createRule_1.createRule)({
339
339
  if (normalizedVarName === normalizedMarker) {
340
340
  return false;
341
341
  }
342
- // Check if it's a prefix with proper boundary (e.g., strName, numCount)
342
+ // Abbreviation markers (str, num, int, bool, arr, obj) are short enough
343
+ // that the raw-character boundary checks below fire on them as
344
+ // substrings inside real English words (e.g. "int" inside "Mint", "str"
345
+ // inside "stream"). The ONLY correct test for an abbreviation marker is
346
+ // therefore an exact match against a full camelCase segment: "Mint" →
347
+ // ["Mint"] never matches "int", while genuine Hungarian like intValue →
348
+ // ["int","Value"] still does.
349
+ //
350
+ // This guard MUST run before the prefix/suffix boundary checks. A
351
+ // capitalized terminal segment makes its own initial capital double as
352
+ // the raw suffix-boundary character: "appendHoldHint" ends with the
353
+ // marker "int" AND its preceding char is the capital "H" of "Hint", so
354
+ // the suffix check below would short-circuit to `true` and short-circuit
355
+ // this guard entirely (issue #1258). Because it always returns, an
356
+ // abbreviation marker never reaches the raw-character heuristics, so
357
+ // words like Hint/Blueprint/Waypoint/Checkpoint/Paint are spared.
358
+ if (ABBREVIATION_MARKER_SET.has(normalizedMarker)) {
359
+ const segments = splitCamelSegments(variableName);
360
+ return segments.some((s) => s.toLowerCase() === normalizedMarker);
361
+ }
362
+ // The prefix/suffix boundary checks below apply only to FULL type-word
363
+ // markers (String, Number, Boolean, Array, Object, ...); abbreviation
364
+ // markers have already returned above.
365
+ // Check if it's a prefix with proper boundary (e.g., stringValue,
366
+ // numberCount)
343
367
  if (normalizedVarName.startsWith(normalizedMarker) &&
344
368
  normalizedVarName.length > normalizedMarker.length &&
345
369
  /[A-Z0-9]/.test(variableName[normalizedMarker.length])) {
@@ -352,16 +376,6 @@ exports.noHungarian = (0, createRule_1.createRule)({
352
376
  /[A-Z]/.test(variableName[variableName.length - normalizedMarker.length]))) {
353
377
  return true;
354
378
  }
355
- // Abbreviation markers (str, num, int, bool, arr, obj, fn, func) are
356
- // short enough that a raw-character boundary check can fire on them as
357
- // substrings inside real English words (e.g. "int" inside "Mint", "str"
358
- // inside "stream"). Require an exact match against a full camelCase
359
- // token so that "Mint" → ["Mint"] never matches "int", while genuine
360
- // Hungarian like intValue → ["int","Value"] still does.
361
- if (ABBREVIATION_MARKER_SET.has(normalizedMarker)) {
362
- const segments = splitCamelSegments(variableName);
363
- return segments.some((s) => s.toLowerCase() === normalizedMarker);
364
- }
365
379
  // Full type-word markers (non-abbreviations: String, Number, Function,
366
380
  // Array, Object, Boolean, …) are Hungarian only when they occupy the
367
381
  // first or last camelCase segment — a genuine prefix or suffix that
@@ -1 +1,3 @@
1
- export declare const preferUseTheme: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"preferUseTheme", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ type MessageIds = 'preferUseTheme' | 'preferUseThemeNoEquivalent';
2
+ export declare const preferUseTheme: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<MessageIds, [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
3
+ export {};
@@ -50,7 +50,9 @@ const BANNED_CONSTANTS = new Set([
50
50
  /**
51
51
  * Map from constant name to its theme-object equivalent path, used in the
52
52
  * error message so that developers know exactly where to find the value on the
53
- * theme object.
53
+ * theme object. Only constants that map to a value the theme actually carries
54
+ * appear here; constants without a faithful location are listed in
55
+ * CONSTANTS_WITHOUT_THEME_EQUIVALENT and get the no-equivalent message instead.
54
56
  */
55
57
  const THEME_EQUIVALENTS = {
56
58
  PALETTE: 'theme.palette',
@@ -70,9 +72,20 @@ const THEME_EQUIVALENTS = {
70
72
  ZINDEX: 'theme.zIndex',
71
73
  ASPECT_RATIO: 'theme.aspectRatio',
72
74
  BREAKPOINTS: 'theme.breakpoints',
73
- BORDER_RADIUS: 'theme.shape.borderRadius',
74
- CONTAINER_WIDTH: 'theme.mixins',
75
75
  };
76
+ /**
77
+ * Banned constants that have NO faithful single location on the theme object:
78
+ * - BORDER_RADIUS is the full M3 shape scale; `theme.shape.borderRadius` is
79
+ * MUI's default `4`, not this scale — following that hint ships a visual
80
+ * regression, so the message points at real alternatives instead.
81
+ * - CONTAINER_WIDTH is not carried anywhere on the theme today.
82
+ * These still bypass the theme system, so they remain flagged — but with the
83
+ * no-equivalent message rather than a mapping that misdirects developers.
84
+ */
85
+ const CONSTANTS_WITHOUT_THEME_EQUIVALENT = new Set([
86
+ 'BORDER_RADIUS',
87
+ 'CONTAINER_WIDTH',
88
+ ]);
76
89
  /**
77
90
  * Target file path fragments that the rule enforces inside. Files outside
78
91
  * these directories are not flagged — this avoids false positives in utility
@@ -130,13 +143,20 @@ exports.preferUseTheme = (0, createRule_1.createRule)({
130
143
  schema: [],
131
144
  messages: {
132
145
  preferUseTheme: "Import '{{importName}}' from '{{sourceModule}}' bypasses the MUI theme system. Use useTheme() and access {{themeEquivalent}} instead.",
146
+ preferUseThemeNoEquivalent: "Import '{{importName}}' from '{{sourceModule}}' bypasses the MUI theme system. This constant has no direct equivalent on the theme object: reuse a theme token that already carries the value (e.g. theme.panels[n].borderRadius) or add {{importName}} to the theme in src/styles/theme.ts and read it via useTheme().",
133
147
  },
134
148
  },
135
149
  defaultOptions: [],
136
150
  create(context) {
137
- const filename = context.getFilename?.() ??
151
+ const rawFilename = context.getFilename?.() ??
138
152
  context.filename ??
139
153
  '';
154
+ // Normalize Windows backslash separators to forward slashes so the
155
+ // forward-slash path fragments below (`src/components/`, `src/styles/`,
156
+ // `__tests__/`) match on every platform. Without this, `getFilename()`
157
+ // returns `C:\repo\src\components\Foo.tsx` on Windows, `isInTargetPath`
158
+ // never matches, and the rule silently no-ops for every file (issue #1259).
159
+ const filename = rawFilename.replace(/\\/g, '/');
140
160
  // Files that build the theme are always exempt.
141
161
  if (isInStylesDir(filename)) {
142
162
  return {};
@@ -186,6 +206,19 @@ exports.preferUseTheme = (0, createRule_1.createRule)({
186
206
  if (!BANNED_CONSTANTS.has(importedName)) {
187
207
  continue;
188
208
  }
209
+ // Constants without a faithful theme location get guidance toward
210
+ // real alternatives rather than a mapping that misdirects (issue #1260).
211
+ if (CONSTANTS_WITHOUT_THEME_EQUIVALENT.has(importedName)) {
212
+ context.report({
213
+ node: specifier,
214
+ messageId: 'preferUseThemeNoEquivalent',
215
+ data: {
216
+ importName: importedName,
217
+ sourceModule: source,
218
+ },
219
+ });
220
+ continue;
221
+ }
189
222
  const themeEquivalent = THEME_EQUIVALENTS[importedName] ??
190
223
  'the theme object via useTheme()';
191
224
  context.report({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.18.0",
3
+ "version": "1.18.1",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "1.18.1",
4
+ "date": "2026-07-06T03:35:53.804Z",
5
+ "rules": [
6
+ {
7
+ "name": "consistent-callback-naming",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1262
11
+ ],
12
+ "summary": "exempt value-returning accessor props (closes #1262)"
13
+ },
14
+ {
15
+ "name": "enforce-positive-naming",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1261
19
+ ],
20
+ "summary": "treat the \"disabled\" word family as valid (closes #1261)"
21
+ },
22
+ {
23
+ "name": "enforce-types-directory-placement",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1263
27
+ ],
28
+ "summary": "exempt frontend-coupled type files (closes #1263)"
29
+ },
30
+ {
31
+ "name": "global-const-style",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1257
35
+ ],
36
+ "summary": "exempt Next.js reserved exports from autofix rename (closes #1257)"
37
+ },
38
+ {
39
+ "name": "no-hungarian",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1258
43
+ ],
44
+ "summary": "don't flag words ending in an abbreviation marker (closes #1258)"
45
+ },
46
+ {
47
+ "name": "prefer-use-theme",
48
+ "changeType": "fix",
49
+ "issues": [
50
+ 1259,
51
+ 1260
52
+ ],
53
+ "summary": "stop pointing BORDER_RADIUS/CONTAINER_WIDTH at absent theme paths (closes #1260); normalize Windows path separators before path checks (closes #1259)"
54
+ }
55
+ ]
56
+ },
2
57
  {
3
58
  "version": "1.18.0",
4
59
  "date": "2026-07-02T16:19:34.385Z",