@blumintinc/eslint-plugin-blumint 1.20.66 → 1.20.68

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.
@@ -3,6 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.preferUnionFromConstArray = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ /**
7
+ * Matches Prettier's own default. The autofix authors a whole statement that a
8
+ * formatter owns, so a line it emits past this width is rewritten on the next
9
+ * `prettier --write` — and fails `prettier --check` in the meantime.
10
+ */
11
+ const DEFAULT_PRINT_WIDTH = 80;
6
12
  /**
7
13
  * Mechanically derive an UPPER_SNAKE constant base from a PascalCase/camelCase
8
14
  * type name by inserting `_` at every lowercase/digit-to-uppercase boundary and
@@ -43,6 +49,53 @@ function isNameInScope(scope, name) {
43
49
  }
44
50
  return false;
45
51
  }
52
+ /**
53
+ * The file's own nesting step, taken as the most common indentation increase
54
+ * between consecutive lines. Reading it from the source keeps emitted code in
55
+ * the author's units instead of assuming a two-space, space-indented file.
56
+ */
57
+ function indentUnitOf(sourceCode) {
58
+ const text = sourceCode.getText();
59
+ const blockComments = sourceCode
60
+ .getAllComments()
61
+ .filter((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Block)
62
+ .map((comment) => comment.range);
63
+ // A block comment's interior lines carry whatever alignment the comment uses
64
+ // — the `*` one column in from its own indentation, or, for commented-out
65
+ // code, the original code's depths. Neither is a nesting step of the file, and
66
+ // counting them makes a JSDoc-heavy file look 1-space indented. Keying on the
67
+ // comment's range rather than a leading `*` also covers a body without them.
68
+ const continuesBlockComment = (offset) => blockComments.some(([start, end]) => start < offset && offset < end);
69
+ const frequencies = new Map();
70
+ let previous = '';
71
+ let offset = 0;
72
+ for (const line of text.split('\n')) {
73
+ const lineStart = offset;
74
+ offset += line.length + 1;
75
+ if (line.trim() === '') {
76
+ continue;
77
+ }
78
+ if (continuesBlockComment(lineStart)) {
79
+ continue;
80
+ }
81
+ const match = /^[ \t]*/.exec(line);
82
+ const indent = match ? match[0] : '';
83
+ if (indent.length > previous.length && indent.startsWith(previous)) {
84
+ const delta = indent.slice(previous.length);
85
+ frequencies.set(delta, (frequencies.get(delta) ?? 0) + 1);
86
+ }
87
+ previous = indent;
88
+ }
89
+ let unit = ' ';
90
+ let best = 0;
91
+ for (const [delta, count] of frequencies) {
92
+ if (count > best) {
93
+ unit = delta;
94
+ best = count;
95
+ }
96
+ }
97
+ return unit;
98
+ }
46
99
  exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
47
100
  name: 'prefer-union-from-const-array',
48
101
  meta: {
@@ -52,14 +105,37 @@ exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
52
105
  recommended: 'error',
53
106
  },
54
107
  fixable: 'code',
55
- schema: [],
108
+ schema: [
109
+ {
110
+ type: 'object',
111
+ properties: {
112
+ printWidth: {
113
+ type: 'number',
114
+ minimum: 1,
115
+ },
116
+ },
117
+ additionalProperties: false,
118
+ },
119
+ ],
56
120
  messages: {
57
121
  preferDerivedUnion: 'Derive this union from an `as const` array: `const {{constName}} = [...] as const; type {{typeName}} = (typeof {{constName}})[number];`. This tethers the type to importable runtime values and prevents silent drift. Consider also naming each literal as its own exported constant so call sites never repeat magic strings.',
58
122
  },
59
123
  },
60
- defaultOptions: [],
61
- create(context) {
124
+ defaultOptions: [{}],
125
+ create(context, [options]) {
62
126
  const sourceCode = context.getSourceCode();
127
+ const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
128
+ ? options.printWidth
129
+ : DEFAULT_PRINT_WIDTH;
130
+ // Derived once per file rather than per fix: every fix in a file shares the
131
+ // author's nesting step.
132
+ let indentUnit = null;
133
+ const fileIndentUnit = () => {
134
+ if (indentUnit === null) {
135
+ indentUnit = indentUnitOf(sourceCode);
136
+ }
137
+ return indentUnit;
138
+ };
63
139
  return {
64
140
  TSTypeAliasDeclaration(node) {
65
141
  const { typeAnnotation } = node;
@@ -103,12 +179,10 @@ exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
103
179
  messageId: 'preferDerivedUnion',
104
180
  data: { constName, typeName },
105
181
  fix(fixer) {
106
- const literals = members
107
- .map((member) => {
182
+ const literals = members.map((member) => {
108
183
  const literal = member.literal;
109
184
  return literal.raw ?? sourceCode.getText(literal);
110
- })
111
- .join(', ');
185
+ });
112
186
  const typeParams = node.typeParameters
113
187
  ? sourceCode.getText(node.typeParameters)
114
188
  : '';
@@ -124,8 +198,39 @@ exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
124
198
  .slice(0, fixNode.loc.start.column)
125
199
  .match(/^\s*/);
126
200
  const indent = indentMatch ? indentMatch[0] : '';
127
- const constDecl = `${constKeyword} ${constName} = [${literals}] as const;`;
128
- const typeDecl = `${typeKeyword} ${typeName}${typeParams} = (typeof ${constName})[number];`;
201
+ // A line break the fixer does not add is one Prettier adds later,
202
+ // so the fix must decide the same way Prettier does: keep the
203
+ // statement on one line while it fits, break it open past the print
204
+ // width. Over-wrapping is not the safe direction here — unlike an
205
+ // object literal, an array Prettier considers short enough is
206
+ // collapsed back onto one line, so a blanket wrap would trade this
207
+ // defect for its mirror image on every short union.
208
+ //
209
+ // The emitted const starts where the replaced node starts, so its
210
+ // column is the exact prefix length; the derived alias is emitted on
211
+ // a fresh line at `indent`.
212
+ const constStartColumn = fixNode.loc.start.column;
213
+ const inlineConst = `${constKeyword} ${constName} = [${literals.join(', ')}] as const;`;
214
+ // A literal whose raw text spans lines (a string written with a
215
+ // backslash line continuation) cannot sit on one line at all, so
216
+ // there is no width to compare — such an array always breaks open.
217
+ // Its raw text is emitted verbatim, leaving the interior line break
218
+ // and everything after it exactly where the author put it: that
219
+ // whitespace is string DATA, not layout.
220
+ const hasMultilineLiteral = literals.some((literal) => /[\r\n]/.test(literal));
221
+ const constDecl = !hasMultilineLiteral &&
222
+ constStartColumn + inlineConst.length <= printWidth
223
+ ? inlineConst
224
+ : `${constKeyword} ${constName} = [\n${literals
225
+ .map((literal) => `${indent}${fileIndentUnit()}${literal},`)
226
+ .join('\n')}\n${indent}] as const;`;
227
+ const inlineType = `${typeKeyword} ${typeName}${typeParams} = (typeof ${constName})[number];`;
228
+ // Prettier answers an over-long type alias by breaking after the
229
+ // `=`, the only break point the derived form has.
230
+ const typeDecl = typeParams.includes('\n') ||
231
+ indent.length + inlineType.length <= printWidth
232
+ ? inlineType
233
+ : `${typeKeyword} ${typeName}${typeParams} =\n${indent}${fileIndentUnit()}(typeof ${constName})[number];`;
129
234
  return fixer.replaceText(fixNode, `${constDecl}\n${indent}${typeDecl}`);
130
235
  },
131
236
  });
@@ -1,6 +1,34 @@
1
1
  import { ESLintUtils } from '@typescript-eslint/utils';
2
+ import type { TSESLint } from '@typescript-eslint/utils';
2
3
  import { RuleTester } from 'eslint';
3
4
  export declare const ruleTesterTs: ESLintUtils.RuleTester;
4
5
  export declare const ruleTesterJsx: ESLintUtils.RuleTester;
5
6
  export declare const ruleTesterJson: RuleTester;
6
7
  export declare const ruleTesterMarkdown: RuleTester;
8
+ type ParserOptionsCarrier = {
9
+ parserOptions?: TSESLint.ParserOptions;
10
+ };
11
+ /**
12
+ * Applies `parserOptions` to every case of a suite.
13
+ *
14
+ * The instances above are the only RuleTester instances the suite may use:
15
+ * corpus-harvesting probes collect cases by stubbing `run` on these exports, so
16
+ * a test file that constructs its own tester is invisible to every one of them
17
+ * and its rule silently drops out of the fixer, convergence, collision and
18
+ * crash sweeps. `src/tests/no-local-rule-tester.test.ts` enforces that.
19
+ *
20
+ * A file needing parser options the shared instances do not declare — a
21
+ * type-aware `project`, a non-default `ecmaVersion` — attaches them per case
22
+ * rather than forking a tester. The shared instances already declare ES module
23
+ * scope analysis, so a case restating `sourceType: 'module'` is redundant but
24
+ * harmless. RuleTester deep merges each case's config over the tester's,
25
+ * and options a case declares itself still win over the ones applied here, so a
26
+ * case keeps any override it already had. Carrying the options on the case also
27
+ * means the harvested corpus records the parser configuration a snippet was
28
+ * written for, which a tester-level setting never exposes to a probe.
29
+ */
30
+ export declare const withParserOptions: <T extends string | ParserOptionsCarrier>(parserOptions: TSESLint.ParserOptions, cases: readonly T[]) => (Exclude<T, string> | {
31
+ code: string;
32
+ parserOptions: TSESLint.ParserOptions;
33
+ })[];
34
+ export {};
@@ -1,14 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ruleTesterMarkdown = exports.ruleTesterJson = exports.ruleTesterJsx = exports.ruleTesterTs = void 0;
3
+ exports.withParserOptions = exports.ruleTesterMarkdown = exports.ruleTesterJson = exports.ruleTesterJsx = exports.ruleTesterTs = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const eslint_1 = require("eslint");
6
+ /**
7
+ * `sourceType: 'module'` matches how the linted codebase actually parses: every
8
+ * consumer source file is an ES module, so its top-level declarations live in a
9
+ * module scope nested inside the global one.
10
+ *
11
+ * `@typescript-eslint/parser` defaults to `'script'`, under which a top-level
12
+ * `import`/`const` still binds — the binding hangs off the *global* scope rather
13
+ * than a module scope. References therefore still resolve either way, but the
14
+ * scope shape differs: under `'script'` `globalScope.variables` carries the
15
+ * file's own declarations and there is no module scope at all. A rule that
16
+ * discriminates on `scope.type`, walks `globalScope.childScopes`, or reads
17
+ * `globalScope.variables` would otherwise be exercised against a shape no real
18
+ * file has. `src/tests/rule-tester-parse-mode.test.ts` pins this.
19
+ */
20
+ const SHARED_PARSER_OPTIONS = {
21
+ sourceType: 'module',
22
+ };
6
23
  exports.ruleTesterTs = new utils_1.ESLintUtils.RuleTester({
7
24
  parser: '@typescript-eslint/parser',
25
+ parserOptions: { ...SHARED_PARSER_OPTIONS },
8
26
  });
9
27
  exports.ruleTesterJsx = new utils_1.ESLintUtils.RuleTester({
10
28
  parser: '@typescript-eslint/parser',
11
29
  parserOptions: {
30
+ ...SHARED_PARSER_OPTIONS,
12
31
  ecmaFeatures: {
13
32
  jsx: true,
14
33
  },
@@ -23,4 +42,33 @@ exports.ruleTesterJson = new eslint_1.RuleTester({
23
42
  exports.ruleTesterMarkdown = new eslint_1.RuleTester({
24
43
  parser: require.resolve('markdown-eslint-parser'),
25
44
  });
45
+ /**
46
+ * Applies `parserOptions` to every case of a suite.
47
+ *
48
+ * The instances above are the only RuleTester instances the suite may use:
49
+ * corpus-harvesting probes collect cases by stubbing `run` on these exports, so
50
+ * a test file that constructs its own tester is invisible to every one of them
51
+ * and its rule silently drops out of the fixer, convergence, collision and
52
+ * crash sweeps. `src/tests/no-local-rule-tester.test.ts` enforces that.
53
+ *
54
+ * A file needing parser options the shared instances do not declare — a
55
+ * type-aware `project`, a non-default `ecmaVersion` — attaches them per case
56
+ * rather than forking a tester. The shared instances already declare ES module
57
+ * scope analysis, so a case restating `sourceType: 'module'` is redundant but
58
+ * harmless. RuleTester deep merges each case's config over the tester's,
59
+ * and options a case declares itself still win over the ones applied here, so a
60
+ * case keeps any override it already had. Carrying the options on the case also
61
+ * means the harvested corpus records the parser configuration a snippet was
62
+ * written for, which a tester-level setting never exposes to a probe.
63
+ */
64
+ const withParserOptions = (parserOptions, cases) => cases.map((testCase) => typeof testCase === 'string'
65
+ ? { code: testCase, parserOptions }
66
+ : {
67
+ ...testCase,
68
+ parserOptions: {
69
+ ...parserOptions,
70
+ ...testCase.parserOptions,
71
+ },
72
+ });
73
+ exports.withParserOptions = withParserOptions;
26
74
  //# sourceMappingURL=ruleTester.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.66",
3
+ "version": "1.20.68",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,89 @@
1
1
  [
2
+ {
3
+ "version": "1.20.68",
4
+ "date": "2026-08-01T18:03:14.950Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-union-from-const-array",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1577
11
+ ],
12
+ "summary": "exclude block comments by range when inferring the indent unit (closes #1577)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.67",
18
+ "date": "2026-08-01T17:17:30.817Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-microdiff",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1571
25
+ ],
26
+ "summary": "guard the zero-argument JSON.stringify comparison (closes #1571)"
27
+ },
28
+ {
29
+ "name": "memo-nested-react-components",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1567
33
+ ],
34
+ "summary": "exempt lowercase-initial render-prop callbacks (closes #1567)"
35
+ },
36
+ {
37
+ "name": "no-compositing-layer-props",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1570
41
+ ],
42
+ "summary": "exempt mix-blend-mode's non-promoting values (closes #1570)"
43
+ },
44
+ {
45
+ "name": "no-jsx-in-hooks",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1572
49
+ ],
50
+ "summary": "guard the zero-argument .map() inside useMemo (closes #1572)"
51
+ },
52
+ {
53
+ "name": "optimize-object-boolean-conditions",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1569
57
+ ],
58
+ "summary": "stop calling boolean-prefixed and primitive identifiers objects (closes #1569)"
59
+ },
60
+ {
61
+ "name": "prefer-getter-over-parameterless-method",
62
+ "changeType": "fix",
63
+ "issues": [
64
+ 1568
65
+ ],
66
+ "summary": "prescribe a remedy that clears preferGetterSideEffect (closes #1568)"
67
+ },
68
+ {
69
+ "name": "prefer-sx-prop-over-system-props",
70
+ "changeType": "fix",
71
+ "issues": [
72
+ 1565,
73
+ 1574
74
+ ],
75
+ "summary": "ignore block-comment lines when inferring the indent unit (closes #1574); wrap emitted sx object when it overflows the print width (closes #1565)"
76
+ },
77
+ {
78
+ "name": "prefer-union-from-const-array",
79
+ "changeType": "fix",
80
+ "issues": [
81
+ 1566
82
+ ],
83
+ "summary": "wrap the authored as-const array when it exceeds the print width (closes #1566)"
84
+ }
85
+ ]
86
+ },
2
87
  {
3
88
  "version": "1.20.66",
4
89
  "date": "2026-08-01T13:53:31.680Z",