@blumintinc/eslint-plugin-blumint 1.20.141 → 1.20.143

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.141',
226
+ version: '1.20.143',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -846,6 +846,115 @@ function carriedText(source, range) {
846
846
  : ' ';
847
847
  return `${lead}${body}${trail}`;
848
848
  }
849
+ /** Every character the syntactic grammar counts as a LineTerminator. */
850
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
851
+ const textOf = (source, range) => source.text.slice(range[0], range[1]);
852
+ /**
853
+ * The span an arrow's return annotation occupies between the parameter list and
854
+ * the `=>`, together with that arrow token.
855
+ *
856
+ * The span holds the annotation, whitespace and comments and nothing else,
857
+ * which is what makes it safe to rewrite wholesale: no binding reference can
858
+ * hide in it beyond the ones the annotation itself names.
859
+ */
860
+ function arrowAnnotationGap(source, returnType) {
861
+ const parametersEnd = source.getTokenBefore(returnType);
862
+ const arrow = source.getTokenAfter(returnType, {
863
+ filter: (token) => token.value === '=>',
864
+ });
865
+ if (!parametersEnd || !arrow)
866
+ return null;
867
+ const gap = [parametersEnd.range[1], arrow.range[0]];
868
+ return containsRange(gap, returnType.range) ? { gap, arrow } : null;
869
+ }
870
+ /**
871
+ * Re-emits `comments` on the far side of the arrow, where a line terminator is
872
+ * inert, consuming the horizontal whitespace the arrow already had after it so
873
+ * the body keeps a single separator.
874
+ */
875
+ function hoistPastArrow(source, arrow, comments) {
876
+ const indent = indentAt(source, arrow.range[0]);
877
+ const trailingText = source.text.slice(arrow.range[1]);
878
+ const [spacing] = /^[ \t]*/.exec(trailingText) ?? [''];
879
+ const body = (0, replacementSegments_1.joinSegmentBody)(comments.map((comment) => ({
880
+ text: textOf(source, comment.range),
881
+ breakAfter: true,
882
+ })), indent);
883
+ const rest = trailingText.slice(spacing.length);
884
+ const separator = LINE_TERMINATOR.test(rest.charAt(0))
885
+ ? ''
886
+ : (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
887
+ ? `\n${indent}`
888
+ : ' ';
889
+ return {
890
+ range: [arrow.range[1], arrow.range[1] + spacing.length],
891
+ text: ` ${body}${separator}`,
892
+ };
893
+ }
894
+ /**
895
+ * The edits that strip one annotation, carrying every comment the strip
896
+ * strands rather than deleting it (#1877). `null` withholds the fix, for a
897
+ * comment whose meaning is its position and which cannot stay where it is.
898
+ *
899
+ * An arrow is the one subject whose annotation sits inside a restricted
900
+ * production: `ArrowParameters [no LineTerminator here] =>` forbids a line
901
+ * terminator between the parameter list and the arrow, and a block comment
902
+ * carrying a line terminator IS one to the grammar. A comment left there — or
903
+ * carried there from inside the annotation — therefore turns the output into a
904
+ * hard SyntaxError that only V8 reports, since `@typescript-eslint/parser`
905
+ * accepts it (#1964). Such a comment is re-emitted past the `=>` instead, the
906
+ * nearest position outside the restricted gap that cannot itself begin one;
907
+ * hoisting it above the enclosing line would anchor an insertion at a column
908
+ * zero that may sit inside a template literal or JSX text, where the comment
909
+ * would become content rather than code.
910
+ *
911
+ * Every other subject ends its parameter list at a body or a semicolon, so its
912
+ * stranded comments stay where they were written.
913
+ */
914
+ function planAnnotationEdits(source, entry) {
915
+ const range = entry.returnType.range;
916
+ if (entry.node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
917
+ const carried = carriedText(source, range);
918
+ return carried === null ? null : [{ range, text: carried }];
919
+ }
920
+ const gapInfo = arrowAnnotationGap(source, entry.returnType);
921
+ if (!gapInfo)
922
+ return null;
923
+ const { gap, arrow } = gapInfo;
924
+ const comments = source
925
+ .getAllComments()
926
+ .filter((comment) => containsRange(gap, comment.range));
927
+ const stranded = comments.filter((comment) => containsRange(range, comment.range));
928
+ // What the plain deletion would leave between the parameters and the arrow.
929
+ // A comment left there contributes its own text, so a line comment or a
930
+ // multi-line block comment shows up here as the line terminator it is.
931
+ const residue = `${textOf(source, [gap[0], range[0]])}${textOf(source, [
932
+ range[1],
933
+ gap[1],
934
+ ])}`;
935
+ // The plain deletion is kept wherever it already lands a legal gap and
936
+ // strands nothing, so no output that survives today moves by a byte.
937
+ if (stranded.length === 0 && !LINE_TERMINATOR.test(residue)) {
938
+ return [{ range, text: '' }];
939
+ }
940
+ // Rewriting the gap collapses the lines it spanned, which moves the line a
941
+ // directive inside it points at, so the whole fix is withheld rather than
942
+ // retargeting one. The gap a directive can share with nothing else is left
943
+ // untouched by the branch above.
944
+ if (comments.some(isPositionalDirective))
945
+ return null;
946
+ const hoisted = comments.filter(replacementSegments_1.requiresOwnLine);
947
+ const inline = comments
948
+ .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
949
+ .map((comment) => textOf(source, comment.range));
950
+ const edits = [
951
+ { range: gap, text: inline.length === 0 ? ' ' : ` ${inline.join(' ')} ` },
952
+ ];
953
+ if (hoisted.length > 0) {
954
+ edits.push(hoistPastArrow(source, arrow, hoisted));
955
+ }
956
+ return edits;
957
+ }
849
958
  /**
850
959
  * ESLint applies a fix whole or not at all, and rejects one whose edits
851
960
  * overlap. Two spans planned independently — an annotation and the declaration
@@ -866,14 +975,23 @@ function isDisjoint(edits) {
866
975
  * outright. The planner computes spans that reach across separators, so a
867
976
  * comment among the specifiers sits inside one; declining on that comment is no
868
977
  * remedy, since it lets a comment decide whether the annotations are stripped at
869
- * all, which is a comment changing the transform just the same (#1877).
978
+ * all, which is a comment changing the transform just the same (#1877). The
979
+ * annotation spans are carried the same way by {@link planAnnotationEdits},
980
+ * which additionally answers for the arrow whose annotation sits inside a
981
+ * restricted production.
870
982
  */
871
983
  function planRemoval(source, removalSource, batch) {
872
984
  const annotations = batch.map((entry) => entry.returnType.range);
873
985
  const cleanups = (0, importRemoval_1.planOrphanedBindingRemoval)(removalSource, annotations, (variables, planned) => (0, typeDeclarationRemoval_1.planTypeDeclarationRemoval)(removalSource, variables, planned));
874
986
  if (!cleanups)
875
987
  return null;
876
- const edits = annotations.map((range) => ({ range, text: '' }));
988
+ const edits = [];
989
+ for (const entry of batch) {
990
+ const planned = planAnnotationEdits(source, entry);
991
+ if (planned === null)
992
+ return null;
993
+ edits.push(...planned);
994
+ }
877
995
  for (const range of cleanups) {
878
996
  if (removesWholeStatement(source, range)) {
879
997
  edits.push({ range, text: '' });
@@ -551,11 +551,13 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
551
551
  // line is hoisted onto a full line of its own ABOVE the line the
552
552
  // call starts on. That insertion can never split a token pair, and
553
553
  // it lands a `-next-line` directive exactly one line above the
554
- // statement that now hosts its subject. Everything else stays
555
- // inline: a block comment beside the expression, and a trailing
556
- // line-bound comment followed by a line break, which is safe after
557
- // the expression has begun.
558
- const hoistedComments = leadingComments.filter(replacementSegments_1.requiresLineBreakAfter);
554
+ // statement that now hosts its subject. A block comment carrying a
555
+ // line terminator demands a line the same way, because the grammar
556
+ // reads it AS a line terminator (#1963). Everything else stays
557
+ // inline: a single-line block comment beside the expression, and a
558
+ // trailing line-bound comment followed by a line break, which is
559
+ // safe after the expression has begun.
560
+ const hoistedComments = leadingComments.filter(replacementSegments_1.requiresOwnLine);
559
561
  if (hoistedComments.length > 0) {
560
562
  const lineStartIndex = sourceCode.getIndexFromLoc({
561
563
  line: node.loc.start.line,
@@ -571,7 +573,7 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
571
573
  }
572
574
  const segments = [
573
575
  ...leadingComments
574
- .filter((comment) => !(0, replacementSegments_1.requiresLineBreakAfter)(comment))
576
+ .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
575
577
  .map(toSegment),
576
578
  { text: replacementText, breakAfter: false },
577
579
  ...trailingComments.map(toSegment),
@@ -146,20 +146,37 @@ const DEFAULT_MUI_COMPONENTS = new Set([
146
146
  'Toolbar',
147
147
  ]);
148
148
  /**
149
- * Components whose public prop API defines `color` as a closed semantic enum
150
- * (a palette / variant selector like `'primary' | 'secondary' | 'error' | …`),
151
- * not a CSS-forwarded system style prop. On these, `color` feeds
152
- * `ownerState.color`, selecting theme variants and MUI's internal
153
- * `.Mui*-color*` class selectors moving it into `sx` both drops the variant
154
- * selection and produces an invalid CSS `color` value. So `color` here is a
155
- * first-class component prop, never a deprecated system prop.
149
+ * Props a component declares as its own first-class API, keyed by the
150
+ * (component, prop) pair. Each name below collides with a system prop, but the
151
+ * component *consumes* it feeding `ownerState`, selecting a theme value and
152
+ * MUI's internal `.Mui*-*` class selectors instead of forwarding it as CSS.
153
+ * The system-prop reading is therefore wrong for the pair whatever value is
154
+ * written, and moving the prop into `sx` emits a declaration whose value is not
155
+ * a CSS value for that property: the browser drops it, so the fix type-checks
156
+ * (`SxProps` accepts `string | number`), lints clean, and silently loses the
157
+ * styling.
158
+ *
159
+ * The keying has to be per pair, not per prop name: the same name is a genuine
160
+ * system prop elsewhere (`color` on `Typography`, `maxWidth` on `Box`), so
161
+ * exempting the bare name would blind the rule on every other component.
156
162
  */
157
- const COMPONENT_COLOR_IS_SEMANTIC = new Set([
158
- 'Button',
159
- 'IconButton',
160
- 'Chip',
161
- 'Badge',
163
+ const COMPONENT_OWN_PROPS = new Map([
164
+ // `color` is a closed palette/variant selector (`'primary' | 'error' | …`)
165
+ // on these — never a CSS color (#1273). On `AppBar` it picks the *background*
166
+ // shade, so the system-prop reading also targets the wrong CSS property.
167
+ ['Button', new Set(['color'])],
168
+ ['IconButton', new Set(['color'])],
169
+ ['Chip', new Set(['color'])],
170
+ ['Badge', new Set(['color'])],
171
+ ['AppBar', new Set(['color'])],
172
+ // `maxWidth` is a breakpoint KEY (`'xs' | … | 'xl' | false`) that selects a
173
+ // width from `theme.breakpoints.values` and drives the `maxWidth*` class
174
+ // (#1966). As CSS, `max-width: xl` is invalid and the element unbounds.
175
+ ['Container', new Set(['maxWidth'])],
176
+ ['Dialog', new Set(['maxWidth'])],
162
177
  ]);
178
+ /** True when `propName` belongs to `componentName`'s own prop API. */
179
+ const componentOwnsProp = (componentName, propName) => COMPONENT_OWN_PROPS.get(componentName)?.has(propName) === true;
163
180
  /**
164
181
  * Props that must never be moved to `sx` because they are genuine component
165
182
  * API props, not MUI system styling shorthands. `direction` and `spacing` are
@@ -782,10 +799,10 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
782
799
  return false;
783
800
  }
784
801
  function isSystemProp(name, componentName) {
785
- // `color` is a semantic enum prop (not a CSS system prop) on components
786
- // like Button/IconButton/Chip/Badge exempt it there so the autofix
787
- // never rewrites a variant selector into an invalid CSS color.
788
- if (name === 'color' && COMPONENT_COLOR_IS_SEMANTIC.has(componentName)) {
802
+ // A prop the component owns is never the system prop of the same name, so
803
+ // the autofix must not rewrite it into a CSS declaration the browser
804
+ // discards.
805
+ if (componentOwnsProp(componentName, name)) {
789
806
  return false;
790
807
  }
791
808
  return MUI_SYSTEM_PROPS.has(name) && !isAllowedProp(name);
@@ -43,7 +43,30 @@ const TESTS_DIR = path.join(__dirname, '..', 'tests');
43
43
  * (`const jsx = ruleTesterJsx`) before calling `run`, so a call-site pattern
44
44
  * drops it.
45
45
  */
46
- exports.IMPORTS_SHARED_TESTER = /from\s+'\.\.\/utils\/ruleTester'/;
46
+ exports.IMPORTS_SHARED_TESTER = /from\s+'(?:\.\.\/)+utils\/ruleTester'/;
47
+ /**
48
+ * Every suite file under the tests root, as a path relative to it.
49
+ *
50
+ * The enumeration is recursive because a suite in a subdirectory is still a
51
+ * suite: `src/tests/rules/` holds three that jest runs and that every
52
+ * harvest-based gate used to miss, while their rules' top-level namesakes kept
53
+ * the per-rule closure green — so the gap read as coverage from every angle
54
+ * that was checked.
55
+ *
56
+ * Paths stay relative rather than collapsing to a basename so that two suites
57
+ * with the same name (`no-circular-references.test.ts` exists at both depths)
58
+ * remain distinguishable, which is what keeps a per-file baseline or a dedupe
59
+ * key from silently merging them.
60
+ */
61
+ function suiteFilesUnder(root) {
62
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
63
+ const full = path.join(dir, entry.name);
64
+ if (entry.isDirectory())
65
+ return walk(full);
66
+ return entry.name.endsWith('.test.ts') ? [path.relative(root, full)] : [];
67
+ });
68
+ return walk(root).sort();
69
+ }
47
70
  /**
48
71
  * Jest registers a test for every `describe`/`it` a loaded module calls, so
49
72
  * loading 271 suites inside a suite would graft their entire test list onto
@@ -156,10 +179,7 @@ function harvestRuleTesterCases() {
156
179
  const realCwd = process.cwd();
157
180
  process.chdir(scratchRoot);
158
181
  try {
159
- const files = fs
160
- .readdirSync(TESTS_DIR)
161
- .filter((file) => file.endsWith('.test.ts'))
162
- .sort();
182
+ const files = suiteFilesUnder(TESTS_DIR);
163
183
  for (const file of files) {
164
184
  const fullPath = path.join(TESTS_DIR, file);
165
185
  if (!exports.IMPORTS_SHARED_TESTER.test(fs.readFileSync(fullPath, 'utf8'))) {
@@ -16,6 +16,25 @@ import { TSESTree } from '@typescript-eslint/utils';
16
16
  * silently retarget one line past its subject.
17
17
  */
18
18
  export declare function requiresLineBreakAfter(comment: TSESTree.Comment): boolean;
19
+ /**
20
+ * A comment that cannot be folded onto the code that follows it, so a fixer
21
+ * placing it ahead of an expression must give it a line of its own.
22
+ *
23
+ * Two kinds qualify. One is the line-bound comment {@link requiresLineBreakAfter}
24
+ * describes, whose meaning is tied to the line it occupies. The other is a block
25
+ * comment containing a line terminator: the syntactic grammar treats such a
26
+ * comment as a LineTerminator in its own right, so it triggers every restricted
27
+ * production a raw newline would. Measured with `node --check`, a block comment
28
+ * on one line between arrow parameters and their arrow parses, while the same
29
+ * comment broken across two lines is a SyntaxError; ahead of a `return`
30
+ * argument, the multi-line form is worse still — it parses, and ASI silently
31
+ * replaces the returned value with `undefined` (#1963).
32
+ *
33
+ * Only fixers emitting text where a newline is meaningful need this;
34
+ * a replacement wrapped in parentheses can never trip a restricted production
35
+ * and can keep such a comment inline.
36
+ */
37
+ export declare function requiresOwnLine(comment: TSESTree.Comment): boolean;
19
38
  export type ReplacementSegment = {
20
39
  text: string;
21
40
  breakAfter: boolean;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.joinSegments = exports.joinSegmentBody = exports.requiresLineBreakAfter = void 0;
3
+ exports.joinSegments = exports.joinSegmentBody = exports.requiresOwnLine = exports.requiresLineBreakAfter = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const disableDirectives_1 = require("./disableDirectives");
6
6
  /**
@@ -26,6 +26,35 @@ function requiresLineBreakAfter(comment) {
26
26
  return (0, disableDirectives_1.parseDisableDirectives)([comment]).some((directive) => directive.kind === 'disable-next-line');
27
27
  }
28
28
  exports.requiresLineBreakAfter = requiresLineBreakAfter;
29
+ /**
30
+ * Whether a comment's own text carries a line terminator, which only a block
31
+ * comment can do.
32
+ */
33
+ function spansMultipleLines(comment) {
34
+ return comment.loc.start.line !== comment.loc.end.line;
35
+ }
36
+ /**
37
+ * A comment that cannot be folded onto the code that follows it, so a fixer
38
+ * placing it ahead of an expression must give it a line of its own.
39
+ *
40
+ * Two kinds qualify. One is the line-bound comment {@link requiresLineBreakAfter}
41
+ * describes, whose meaning is tied to the line it occupies. The other is a block
42
+ * comment containing a line terminator: the syntactic grammar treats such a
43
+ * comment as a LineTerminator in its own right, so it triggers every restricted
44
+ * production a raw newline would. Measured with `node --check`, a block comment
45
+ * on one line between arrow parameters and their arrow parses, while the same
46
+ * comment broken across two lines is a SyntaxError; ahead of a `return`
47
+ * argument, the multi-line form is worse still — it parses, and ASI silently
48
+ * replaces the returned value with `undefined` (#1963).
49
+ *
50
+ * Only fixers emitting text where a newline is meaningful need this;
51
+ * a replacement wrapped in parentheses can never trip a restricted production
52
+ * and can keep such a comment inline.
53
+ */
54
+ function requiresOwnLine(comment) {
55
+ return requiresLineBreakAfter(comment) || spansMultipleLines(comment);
56
+ }
57
+ exports.requiresOwnLine = requiresOwnLine;
29
58
  /**
30
59
  * Joins the inlined expression and its carried comments into one run of text,
31
60
  * keeping each comment on the side of the expression it was written on and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.141",
3
+ "version": "1.20.143",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.20.143",
4
+ "date": "2026-08-12T07:48:25.970Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-sx-prop-over-system-props",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1966
11
+ ],
12
+ "summary": "key the exemption on (component, prop) (closes #1966)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.142",
18
+ "date": "2026-08-12T07:04:58.677Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-explicit-return-type",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1964
25
+ ],
26
+ "summary": "keep the annotation's comments clear of the arrow gap (closes #1964)"
27
+ },
28
+ {
29
+ "name": "no-usememo-for-pass-by-value",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1963
33
+ ],
34
+ "summary": "hoist a carried multi-line comment clear of the return (closes #1963)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.141",
4
40
  "date": "2026-08-12T00:57:09.340Z",