@blumintinc/eslint-plugin-blumint 1.20.119 → 1.20.120

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.119',
226
+ version: '1.20.120',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -37,6 +37,58 @@ const DIFF_FUNCTION_NAMES = new Set([
37
37
  'detailedDiff',
38
38
  // 'fastDeepEqual' and 'isEqual' stay out: they are allowed alternatives.
39
39
  ]);
40
+ /**
41
+ * The names a hand-rolled comparison function is conventionally given. One
42
+ * shared set answers for both spellings of such a function — a `function`
43
+ * declaration and an arrow bound to a `const` — so neither can drift into
44
+ * recognising a name the other misses.
45
+ */
46
+ const COMPARISON_FUNCTION_NAMES = new Set([
47
+ 'detectChanges',
48
+ 'hasConfigChanged',
49
+ 'compareObjects',
50
+ 'compareArrays',
51
+ 'findChanges',
52
+ 'detectDifferences',
53
+ 'hasStateChanged',
54
+ 'stateHasUpdated',
55
+ 'arrayHasChanged',
56
+ 'settingsChanged',
57
+ ]);
58
+ /**
59
+ * The comparison function whose body the fix rewrites. A name alone does not
60
+ * fix what such a function returns — a boolean, the changed keys, the changes
61
+ * themselves — and a change list swapped in for the wrong one of those
62
+ * compiles, so the rest of the set is reported and left to its authors.
63
+ */
64
+ const REWRITABLE_COMPARISON_NAME = 'hasConfigChanged';
65
+ /**
66
+ * The markers that make a body look like a hand-rolled comparison. Text is
67
+ * enough to raise the report because the report says only that the body should
68
+ * be using microdiff; nothing is rewritten off these.
69
+ */
70
+ function hasComparisonMarkers(bodyText) {
71
+ return (bodyText.includes('JSON.stringify') ||
72
+ bodyText.includes('Object.keys') ||
73
+ bodyText.includes('for (') ||
74
+ bodyText.includes('.some(') ||
75
+ bodyText.includes('.every('));
76
+ }
77
+ /**
78
+ * Whether a comparison function is one whose body the fix attempts. The gate is
79
+ * a text-level pre-filter over the name and the body: `collectStringifyComparisons`
80
+ * reads the AST afterwards and has the last word on whether a rewrite exists,
81
+ * so a body that clears this gate is still routinely left alone.
82
+ *
83
+ * The `!==` marker keeps the rewrite to bodies that phrase the question the way
84
+ * the name does — "has it changed?" — while an all-`===` body is reported and
85
+ * left for its author in either spelling.
86
+ */
87
+ function isRewritableComparison(name, bodyText) {
88
+ return (name === REWRITABLE_COMPARISON_NAME &&
89
+ bodyText.includes('JSON.stringify') &&
90
+ bodyText.includes('!=='));
91
+ }
40
92
  /**
41
93
  * The exports of a competing library whose call sites this rule rewrites,
42
94
  * whatever local name the import binds them to.
@@ -457,6 +509,62 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
457
509
  const emptiness = comparison.isEqual ? '.length === 0' : '.length > 0';
458
510
  return `${DIFF_NAME}(${left}, ${right})${emptiness}`;
459
511
  }
512
+ /**
513
+ * The rewrite of a comparison function's body, or null when the body offers
514
+ * no single expression to replace or the emitted `diff` would not reach
515
+ * microdiff at `fn`.
516
+ *
517
+ * Exactly one comparison is the condition for a fix. With none there is no
518
+ * expression to rewrite, and with several the rule cannot tell which one the
519
+ * function's answer turns on, so the report stands on its own.
520
+ *
521
+ * Only the comparison's own range is rewritten. The signature keeps its type
522
+ * annotations, its modifiers and any `export` in front of it, and the body
523
+ * keeps everything the comparison shares it with: side effects, guard
524
+ * clauses, locals, and the comments around them. Re-emitting the body as a
525
+ * single return drops all of that silently — the fix compiles, so nothing
526
+ * downstream flags the loss.
527
+ *
528
+ * Replacing the comparison rather than the statement holding it is also what
529
+ * lets one implementation serve every spelling of the function: an arrow's
530
+ * concise expression body takes no `return` and no semicolon, and it needs
531
+ * none, because the text around the comparison is never part of the range.
532
+ */
533
+ function buildComparisonBodyFix(fixer, fn, body) {
534
+ const comparisons = collectStringifyComparisons(body);
535
+ if (comparisons.length !== 1 || !canEmitDiffAt(fn)) {
536
+ return null;
537
+ }
538
+ const bodyFix = fixer.replaceText(comparisons[0].node, buildDiffComparison(comparisons[0]));
539
+ const importFix = buildMicrodiffImportFix(fixer);
540
+ return importFix ? [importFix, bodyFix] : bodyFix;
541
+ }
542
+ /**
543
+ * Reports a hand-rolled comparison function, carrying the rewrite whenever
544
+ * its body offers one. Both spellings route through here so an identical
545
+ * violation is auto-remediable however it is written: a report with a fix in
546
+ * one spelling and without it in the other leaves the same code manual to
547
+ * resolve for no reason the author can see.
548
+ */
549
+ function reportComparisonFunction(node, name, body) {
550
+ const bodyText = sourceCode.getText(body);
551
+ if (isRewritableComparison(name, bodyText)) {
552
+ reportedNodes.add(node);
553
+ context.report({
554
+ node,
555
+ messageId: 'enforceMicrodiff',
556
+ fix: (fixer) => buildComparisonBodyFix(fixer, node, body),
557
+ });
558
+ return;
559
+ }
560
+ if (hasComparisonMarkers(bodyText)) {
561
+ reportedNodes.add(node);
562
+ context.report({
563
+ node,
564
+ messageId: 'enforceMicrodiff',
565
+ });
566
+ }
567
+ }
460
568
  // Add a specific set to track which import names are used
461
569
  const usedImportNames = new Set();
462
570
  // Check if a node is an object or array type
@@ -692,71 +800,14 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
692
800
  if (reportedNodes.has(node)) {
693
801
  return;
694
802
  }
695
- // Look for functions that might be implementing diff logic
696
- if (node.id &&
697
- [
698
- 'detectChanges',
699
- 'hasConfigChanged',
700
- 'compareObjects',
701
- 'compareArrays',
702
- 'findChanges',
703
- 'detectDifferences',
704
- 'hasStateChanged',
705
- 'stateHasUpdated',
706
- 'arrayHasChanged',
707
- 'settingsChanged',
708
- ].includes(node.id.name)) {
709
- // Check if function has two parameters that might be objects/arrays
710
- if (node.params.length >= 2) {
711
- const body = node.body;
712
- const bodyText = sourceCode.getText(body);
713
- // Check if the function body contains a JSON.stringify comparison
714
- if (node.id.name === 'hasConfigChanged' &&
715
- bodyText.includes('JSON.stringify') &&
716
- bodyText.includes('!==')) {
717
- reportedNodes.add(node);
718
- // Exactly one comparison is the condition for a fix. With none
719
- // there is no expression to rewrite, and with several the rule
720
- // cannot tell which one the function's answer turns on, so the
721
- // report stands on its own.
722
- const comparisons = collectStringifyComparisons(body);
723
- const comparison = comparisons.length === 1 ? comparisons[0] : null;
724
- context.report({
725
- node,
726
- messageId: 'enforceMicrodiff',
727
- fix(fixer) {
728
- if (!comparison || !canEmitDiffAt(node)) {
729
- return null;
730
- }
731
- // Only the comparison's own range is rewritten. The signature
732
- // keeps its type annotations, its modifiers and any `export`
733
- // in front of it, and the body keeps everything the
734
- // comparison shares it with: side effects, guard clauses,
735
- // locals, and the comments around them. Re-emitting the body
736
- // as a single return drops all of that silently — the fix
737
- // compiles, so nothing downstream flags the loss.
738
- const bodyFix = fixer.replaceText(comparison.node, buildDiffComparison(comparison));
739
- const importFix = buildMicrodiffImportFix(fixer);
740
- return importFix ? [importFix, bodyFix] : bodyFix;
741
- },
742
- });
743
- return;
744
- }
745
- // Look for patterns that suggest object/array comparison
746
- const hasComparisonLogic = bodyText.includes('JSON.stringify') ||
747
- bodyText.includes('Object.keys') ||
748
- bodyText.includes('for (') ||
749
- bodyText.includes('.some(') ||
750
- bodyText.includes('.every(');
751
- if (hasComparisonLogic) {
752
- reportedNodes.add(node);
753
- context.report({
754
- node,
755
- messageId: 'enforceMicrodiff',
756
- });
757
- }
758
- }
803
+ // Two parameters are what a comparison function needs, and what the
804
+ // `diff(a, b)` it is rewritten to needs as well.
805
+ if (!node.id ||
806
+ !COMPARISON_FUNCTION_NAMES.has(node.id.name) ||
807
+ node.params.length < 2) {
808
+ return;
759
809
  }
810
+ reportComparisonFunction(node, node.id.name, node.body);
760
811
  },
761
812
  // Check for custom deep comparison in arrow functions
762
813
  ArrowFunctionExpression(node) {
@@ -764,42 +815,17 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
764
815
  if (reportedNodes.has(node)) {
765
816
  return;
766
817
  }
767
- // Only check arrow functions assigned to variables with comparison-like names
818
+ // The name an arrow answers to is the one its declarator binds, so an
819
+ // arrow passed straight to a call names nothing and is left alone.
768
820
  const parent = node.parent;
769
- if (parent &&
770
- parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
771
- parent.id.type === utils_1.AST_NODE_TYPES.Identifier &&
772
- [
773
- 'detectChanges',
774
- 'hasConfigChanged',
775
- 'compareObjects',
776
- 'compareArrays',
777
- 'findChanges',
778
- 'detectDifferences',
779
- 'hasStateChanged',
780
- 'stateHasUpdated',
781
- 'arrayHasChanged',
782
- 'settingsChanged',
783
- ].includes(parent.id.name)) {
784
- // Check if function has two parameters that might be objects/arrays
785
- if (node.params.length >= 2) {
786
- const body = node.body;
787
- // Look for patterns that suggest object/array comparison
788
- const bodyText = sourceCode.getText(body);
789
- const hasComparisonLogic = bodyText.includes('JSON.stringify') ||
790
- bodyText.includes('Object.keys') ||
791
- bodyText.includes('for (') ||
792
- bodyText.includes('.some(') ||
793
- bodyText.includes('.every(');
794
- if (hasComparisonLogic) {
795
- reportedNodes.add(node);
796
- context.report({
797
- node,
798
- messageId: 'enforceMicrodiff',
799
- });
800
- }
801
- }
821
+ if (!parent ||
822
+ parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
823
+ parent.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
824
+ !COMPARISON_FUNCTION_NAMES.has(parent.id.name) ||
825
+ node.params.length < 2) {
826
+ return;
802
827
  }
828
+ reportComparisonFunction(node, parent.id.name, node.body);
803
829
  },
804
830
  };
805
831
  },
@@ -131,6 +131,30 @@ function hasEmptyDepsArray(callNode) {
131
131
  const deps = callNode.arguments[1];
132
132
  return (deps.type === utils_1.AST_NODE_TYPES.ArrayExpression && deps.elements.length === 0);
133
133
  }
134
+ /**
135
+ * Wrappers that exist purely at the type level: they leave the wrapped
136
+ * expression's runtime value untouched, so a value wrapped in them is still the
137
+ * value the enclosing declarator binds.
138
+ */
139
+ const TYPE_ONLY_WRAPPERS = new Set([
140
+ utils_1.AST_NODE_TYPES.TSAsExpression,
141
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
142
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
143
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
144
+ ]);
145
+ /**
146
+ * Returns the nearest ancestor that carries runtime meaning, skipping the
147
+ * type-only wrappers that may sit between an expression and its binding site.
148
+ * A double assertion (`as unknown as T`) nests two of them, so the climb loops
149
+ * rather than peeling a single layer.
150
+ */
151
+ function getRuntimeParent(node) {
152
+ let current = node.parent;
153
+ while (current && TYPE_ONLY_WRAPPERS.has(current.type)) {
154
+ current = current.parent;
155
+ }
156
+ return current;
157
+ }
134
158
  /**
135
159
  * Checks whether the given identifier (the ref variable name) has its
136
160
  * `.current` property assigned anywhere in the enclosing function body.
@@ -398,8 +422,13 @@ exports.preferUseBase62Id = (0, createRule_1.createRule)({
398
422
  return;
399
423
  if (!useRefArgContainsUuid(node, trackedUuidNames))
400
424
  return;
401
- // Find the variable name assigned to the ref
402
- const parent = node.parent;
425
+ // Find the variable name assigned to the ref. A type assertion
426
+ // between the call and its declarator is semantically neutral, so the
427
+ // name stays knowable through it and the `.current`-reassignment
428
+ // exemption still applies. Genuinely nameless shapes — a destructure,
429
+ // a discarded call, a returned ref — leave `refName` null and keep
430
+ // the conservative report.
431
+ const parent = getRuntimeParent(node);
403
432
  let refName = null;
404
433
  if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
405
434
  parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
@@ -72,9 +72,8 @@ export declare const ruleNameByIdentity: Map<unknown, string>;
72
72
  *
73
73
  * `RuleTester` passes `undefined` in that situation, which ESLint renders as
74
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.
75
+ * silent and contributes nothing. A bare `file`/`react` basename is the smallest
76
+ * departure that keeps those rules reachable.
78
77
  */
79
78
  export declare const defaultFilenameFor: (testCase: FixtureCase) => string;
80
79
  /**
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
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
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const typescript_estree_1 = require("@typescript-eslint/typescript-estree");
9
10
  const harvestRuleTesterCases_1 = require("./harvestRuleTesterCases");
10
11
  /**
11
12
  * The fixture corpus every fixer guard probes, keyed by RULE NAME.
@@ -98,17 +99,65 @@ exports.typeAwareRuleNames = new Set(fs_1.default
98
99
  * suites and `../index` resolve to the same module instance under jest.
99
100
  */
100
101
  exports.ruleNameByIdentity = new Map(Object.entries(plugin.rules).map(([name, rule]) => [rule, name]));
102
+ const parsesWithJsx = (code, jsx) => {
103
+ try {
104
+ // `range`/`loc` are not optional in practice: without them any comment in
105
+ // the snippet throws, which reads as an unparsable fixture.
106
+ (0, typescript_estree_1.parse)(code, { jsx, range: true, loc: true });
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ };
113
+ /** Decided once per snippet+preference; the corpus reprobes the same code. */
114
+ const extensionByCode = new Map();
115
+ /**
116
+ * `.ts` and `.tsx` are not ordered by permissiveness, so neither one can be the
117
+ * blanket default: only `.ts` accepts the angle-bracket assertion `<T>expr`, and
118
+ * only `.tsx` accepts JSX. The tester supplies a PREFERENCE, and the snippet
119
+ * overrides it only when that preference cannot parse the snippet at all.
120
+ *
121
+ * Taking the tester's word for it instead makes a JSX fixture in a `ruleTesterTs`
122
+ * suite a FATAL parse. Every consumer filters messages by `ruleId`, so the fatal
123
+ * is indistinguishable from the rule staying silent — a false clean over 168
124
+ * cases, four fifths of some rules' corpora. The converse costs one case: an
125
+ * angle-bracket assertion declared in a `ruleTesterJsx` suite.
126
+ *
127
+ * Correcting only on a fatal is what keeps this from churning: a snippet legal
128
+ * both ways stays on the extension it has always been probed under, so no
129
+ * path-gated rule silently changes which fixtures reach it.
130
+ */
131
+ const extensionFor = (code, preferred) => {
132
+ const key = `${preferred}\u0000${code}`;
133
+ const cached = extensionByCode.get(key);
134
+ if (cached)
135
+ return cached;
136
+ const alternate = preferred === '.tsx' ? '.ts' : '.tsx';
137
+ // Only a `<` can make the two disagree, so the common case never parses.
138
+ const extension = !code.includes('<') || parsesWithJsx(code, preferred === '.tsx')
139
+ ? preferred
140
+ : parsesWithJsx(code, alternate === '.tsx')
141
+ ? alternate
142
+ : preferred;
143
+ extensionByCode.set(key, extension);
144
+ return extension;
145
+ };
101
146
  /**
102
147
  * The filename a case is probed under when it declares none.
103
148
  *
104
149
  * `RuleTester` passes `undefined` in that situation, which ESLint renders as
105
150
  * `<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.
151
+ * silent and contributes nothing. A bare `file`/`react` basename is the smallest
152
+ * departure that keeps those rules reachable.
109
153
  */
110
- const defaultFilenameFor = (testCase) => testCase.filename ??
111
- (testCase.tester === 'ruleTesterJsx' ? 'react.tsx' : 'file.ts');
154
+ const defaultFilenameFor = (testCase) => {
155
+ if (testCase.filename)
156
+ return testCase.filename;
157
+ const jsxTester = testCase.tester === 'ruleTesterJsx';
158
+ const basename = jsxTester ? 'react' : 'file';
159
+ return `${basename}${extensionFor(testCase.code, jsxTester ? '.tsx' : '.ts')}`;
160
+ };
112
161
  exports.defaultFilenameFor = defaultFilenameFor;
113
162
  /**
114
163
  * Second-chance filenames, used ONLY for a rule that produced no probe at all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.119",
3
+ "version": "1.20.120",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "1.20.120",
4
+ "date": "2026-08-06T05:46:26.078Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-microdiff",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1784
11
+ ],
12
+ "summary": "give the arrow spelling the rewrite its declaration twin carries (closes #1784)"
13
+ },
14
+ {
15
+ "name": "prefer-use-base62-id",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1782
19
+ ],
20
+ "summary": "look through type-only wrappers to the ref name (closes #1782)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.20.119",
4
26
  "date": "2026-08-06T04:34:04.981Z",