@blumintinc/eslint-plugin-blumint 1.20.193 → 1.20.195
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 +1 -1
- package/lib/rules/enforce-typescript-markdown-code-blocks.js +119 -1
- package/lib/rules/no-useless-fragment.js +235 -27
- package/lib/utils/reactFragmentBinding.d.ts +27 -0
- package/lib/utils/reactFragmentBinding.js +74 -0
- package/package.json +2 -1
- package/release-manifest.json +28 -0
package/lib/index.js
CHANGED
|
@@ -16,6 +16,10 @@ const MIN_FENCE_LENGTH = 3;
|
|
|
16
16
|
const MAX_FENCE_INDENT_COLUMNS = 3;
|
|
17
17
|
/** CommonMark advances a tab to the next multiple of four when measuring indent. */
|
|
18
18
|
const TAB_STOP = 4;
|
|
19
|
+
/** The three bullet characters CommonMark accepts for an unordered list item. */
|
|
20
|
+
const BULLET_MARKERS = new Set(['-', '+', '*']);
|
|
21
|
+
/** CommonMark caps an ordered list marker at nine digits before its delimiter. */
|
|
22
|
+
const MAX_ORDERED_MARKER_DIGITS = 9;
|
|
19
23
|
function splitLines(text) {
|
|
20
24
|
const lines = [];
|
|
21
25
|
let start = 0;
|
|
@@ -72,6 +76,106 @@ function readFence(line) {
|
|
|
72
76
|
infoString: line.text.slice(offset + runLength),
|
|
73
77
|
};
|
|
74
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Reads the list marker at `offset`, including the whitespace that separates
|
|
81
|
+
* it from the item's content, or 0 when no marker starts there. CommonMark
|
|
82
|
+
* requires that separator, which is what keeps `*emphasis*` and the thematic
|
|
83
|
+
* break `***` from reading as list items.
|
|
84
|
+
*/
|
|
85
|
+
function readListMarker(text, offset) {
|
|
86
|
+
let cursor = offset;
|
|
87
|
+
const char = text[cursor];
|
|
88
|
+
if (char !== undefined && BULLET_MARKERS.has(char)) {
|
|
89
|
+
cursor += 1;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
let digits = 0;
|
|
93
|
+
while (digits < MAX_ORDERED_MARKER_DIGITS &&
|
|
94
|
+
text[cursor + digits] >= '0' &&
|
|
95
|
+
text[cursor + digits] <= '9') {
|
|
96
|
+
digits += 1;
|
|
97
|
+
}
|
|
98
|
+
const delimiter = text[cursor + digits];
|
|
99
|
+
if (digits === 0 || (delimiter !== '.' && delimiter !== ')')) {
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
cursor += digits + 1;
|
|
103
|
+
}
|
|
104
|
+
if (text[cursor] !== ' ' && text[cursor] !== '\t') {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
while (text[cursor] === ' ' || text[cursor] === '\t') {
|
|
108
|
+
cursor += 1;
|
|
109
|
+
}
|
|
110
|
+
return cursor - offset;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Reads a fence opened on the same line as one or more list markers, as in
|
|
114
|
+
* "- ```ts". A list item's content begins after its marker, so the backticks
|
|
115
|
+
* open a block there exactly as they would at the head of a line.
|
|
116
|
+
*
|
|
117
|
+
* `readFence` cannot see one, because it stops at the first character that is
|
|
118
|
+
* neither a space nor a tab. Without this the scanner walks INTO such a block
|
|
119
|
+
* and mistakes its closing fence — which is nothing but spaces and backticks —
|
|
120
|
+
* for an opening one, appending a language to literal document content.
|
|
121
|
+
*
|
|
122
|
+
* The block is skipped whole rather than labeled: a fix would have to be
|
|
123
|
+
* written past the marker, and labeling a block this rule cannot delimit at
|
|
124
|
+
* the head of a line is the false-negative direction it deliberately keeps.
|
|
125
|
+
*/
|
|
126
|
+
function readListItemFence(line) {
|
|
127
|
+
let indentColumns = 0;
|
|
128
|
+
let offset = 0;
|
|
129
|
+
let sawMarker = false;
|
|
130
|
+
for (;;) {
|
|
131
|
+
while (offset < line.text.length) {
|
|
132
|
+
const char = line.text[offset];
|
|
133
|
+
if (char === ' ') {
|
|
134
|
+
indentColumns += 1;
|
|
135
|
+
}
|
|
136
|
+
else if (char === '\t') {
|
|
137
|
+
indentColumns += TAB_STOP - (indentColumns % TAB_STOP);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
offset += 1;
|
|
143
|
+
}
|
|
144
|
+
if (indentColumns > MAX_FENCE_INDENT_COLUMNS) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const markerLength = readListMarker(line.text, offset);
|
|
148
|
+
if (markerLength === 0) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
offset += markerLength;
|
|
152
|
+
// The item's content starts a fresh indent budget, so a fence sitting at
|
|
153
|
+
// the content column is at indent zero however deep the nesting is.
|
|
154
|
+
indentColumns = 0;
|
|
155
|
+
sawMarker = true;
|
|
156
|
+
}
|
|
157
|
+
if (!sawMarker) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const marker = line.text[offset];
|
|
161
|
+
if (marker !== BACKTICK && marker !== TILDE) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let runLength = 0;
|
|
165
|
+
while (line.text[offset + runLength] === marker) {
|
|
166
|
+
runLength += 1;
|
|
167
|
+
}
|
|
168
|
+
if (runLength < MIN_FENCE_LENGTH) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
runStart: line.start + offset,
|
|
173
|
+
marker,
|
|
174
|
+
runLength,
|
|
175
|
+
indent: line.text.slice(0, offset),
|
|
176
|
+
infoString: line.text.slice(offset + runLength),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
75
179
|
/**
|
|
76
180
|
* The line a block closes on, per CommonMark: a run of at least the opening
|
|
77
181
|
* length, of the SAME marker, at a fence indent, with nothing but whitespace
|
|
@@ -131,7 +235,21 @@ exports.enforceTypescriptMarkdownCodeBlocks = (0, createRule_1.createRule)({
|
|
|
131
235
|
const openingLine = lines[index];
|
|
132
236
|
const fence = readFence(openingLine);
|
|
133
237
|
if (fence === null) {
|
|
134
|
-
|
|
238
|
+
const listFence = readListItemFence(openingLine);
|
|
239
|
+
if (listFence === null) {
|
|
240
|
+
index += 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// A fence opened on a list marker's line is one this rule declines
|
|
244
|
+
// to label, and declining to label a block means declining to read
|
|
245
|
+
// it. Skipping it whole is what keeps its closing fence — spaces
|
|
246
|
+
// and backticks, indistinguishable from an opener — out of the
|
|
247
|
+
// walk.
|
|
248
|
+
const listClosing = findFenceCloser(lines, index + 1, listFence);
|
|
249
|
+
if (listClosing === null) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
index = listClosing.line + 1;
|
|
135
253
|
continue;
|
|
136
254
|
}
|
|
137
255
|
const closing = findFenceCloser(lines, index + 1, fence);
|
|
@@ -3,16 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noUselessFragment = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const reactFragmentBinding_1 = require("../utils/reactFragmentBinding");
|
|
8
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
9
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
6
10
|
/**
|
|
7
11
|
* Normalizes JSX child node types into short descriptors used inside lint messages.
|
|
8
12
|
* Keeps message phrasing consistent regardless of the specific child node shape.
|
|
13
|
+
* A long-form fragment child (`<Fragment>`, `<React.Fragment>`) is described as
|
|
14
|
+
* a fragment rather than a JSX element so the message reads the same whichever
|
|
15
|
+
* spelling the nested fragment uses.
|
|
9
16
|
* @param child - The JSX child node to describe.
|
|
17
|
+
* @param isFragmentElement - Recognizes the long-form fragment spellings.
|
|
10
18
|
* @returns Human-readable descriptor for the child type used in lint messages.
|
|
11
19
|
*/
|
|
12
|
-
const describeChild = (child) => {
|
|
20
|
+
const describeChild = (child, isFragmentElement) => {
|
|
13
21
|
switch (child.type) {
|
|
14
22
|
case 'JSXElement':
|
|
15
|
-
return 'JSX element';
|
|
23
|
+
return isFragmentElement(child) ? 'fragment' : 'JSX element';
|
|
16
24
|
case 'JSXFragment':
|
|
17
25
|
return 'fragment';
|
|
18
26
|
case 'JSXText':
|
|
@@ -112,44 +120,244 @@ const reindentPromotedChild = (sourceCode, fragment, child) => {
|
|
|
112
120
|
})
|
|
113
121
|
.join('\n');
|
|
114
122
|
};
|
|
123
|
+
/** The opening and closing tag spans of either fragment spelling. */
|
|
124
|
+
const tagRangesOf = (node) => {
|
|
125
|
+
if (node.type === 'JSXFragment') {
|
|
126
|
+
return [node.openingFragment.range, node.closingFragment.range];
|
|
127
|
+
}
|
|
128
|
+
return node.closingElement
|
|
129
|
+
? [node.openingElement.range, node.closingElement.range]
|
|
130
|
+
: [node.openingElement.range];
|
|
131
|
+
};
|
|
132
|
+
const contains = (outer, inner) => outer[0] <= inner[0] && inner[1] <= outer[1];
|
|
133
|
+
/**
|
|
134
|
+
* Partitions violations into the sets whose unwraps have to travel together.
|
|
135
|
+
*
|
|
136
|
+
* A `Fragment` import read by two useless `<Fragment>` elements is orphaned only
|
|
137
|
+
* once BOTH are unwrapped, so neither unwrap may drop the import alone — and a
|
|
138
|
+
* fix may only count on the other unwrap happening if it performs that unwrap
|
|
139
|
+
* itself. Fragments that jointly hold a binding alive therefore become one
|
|
140
|
+
* batch; every other fragment is a batch of one, judged against the file as it
|
|
141
|
+
* stands. Shorthand `<>` names nothing, so it is never unioned with anything and
|
|
142
|
+
* keeps fixing independently.
|
|
143
|
+
*
|
|
144
|
+
* EVERY binding is asked about, not the imported ones alone, so that a binding
|
|
145
|
+
* no fix asks about cannot end up owned by none of them.
|
|
146
|
+
*/
|
|
147
|
+
function batchViolations(source, violations) {
|
|
148
|
+
const parents = violations.map((_violation, index) => index);
|
|
149
|
+
const find = (index) => {
|
|
150
|
+
let current = index;
|
|
151
|
+
while (parents[current] !== current) {
|
|
152
|
+
parents[current] = parents[parents[current]];
|
|
153
|
+
current = parents[current];
|
|
154
|
+
}
|
|
155
|
+
return current;
|
|
156
|
+
};
|
|
157
|
+
const union = (left, right) => {
|
|
158
|
+
const rootLeft = find(left);
|
|
159
|
+
const rootRight = find(right);
|
|
160
|
+
if (rootLeft !== rootRight) {
|
|
161
|
+
parents[rootRight] = rootLeft;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const ownerOf = (use) => violations.findIndex(({ tags }) => tags.some((tag) => use[0] >= tag[0] && use[1] <= tag[1]));
|
|
165
|
+
for (const { uses } of (0, importRemoval_1.bindingUses)(source)) {
|
|
166
|
+
if (uses.length < 2)
|
|
167
|
+
continue;
|
|
168
|
+
const owners = new Set();
|
|
169
|
+
const escapes = uses.some((use) => {
|
|
170
|
+
const owner = ownerOf(use);
|
|
171
|
+
if (owner === -1)
|
|
172
|
+
return true;
|
|
173
|
+
owners.add(owner);
|
|
174
|
+
return false;
|
|
175
|
+
});
|
|
176
|
+
// A use outside every unwrap keeps the binding alive whatever these fixes
|
|
177
|
+
// do, so their fixes owe each other nothing.
|
|
178
|
+
if (escapes || owners.size < 2)
|
|
179
|
+
continue;
|
|
180
|
+
const [first, ...rest] = [...owners];
|
|
181
|
+
for (const other of rest) {
|
|
182
|
+
union(first, other);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const groups = new Map();
|
|
186
|
+
violations.forEach((violation, index) => {
|
|
187
|
+
const root = find(index);
|
|
188
|
+
const group = groups.get(root);
|
|
189
|
+
if (group) {
|
|
190
|
+
group.push(violation);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
groups.set(root, [violation]);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return [...groups.values()];
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The unwraps `batch` performs plus the import specifiers they leave bound to
|
|
200
|
+
* nothing. `null` when an orphan cannot be unbound safely — an import behind a
|
|
201
|
+
* directive comment, say — in which case the caller keeps the report and drops
|
|
202
|
+
* the fix, because unwrapping while leaving the import behind turns a clean file
|
|
203
|
+
* into one that fails `no-unused-vars`.
|
|
204
|
+
*/
|
|
205
|
+
function planBatch(source, batch) {
|
|
206
|
+
const unwraps = batch.map((violation) => ({
|
|
207
|
+
node: violation.node,
|
|
208
|
+
// Only fixable violations reach a plan, so the replacement is present.
|
|
209
|
+
text: violation.replacement,
|
|
210
|
+
}));
|
|
211
|
+
const cleanups = (0, importRemoval_1.planOrphanedImportRemoval)(source, batch.flatMap((violation) => violation.tags));
|
|
212
|
+
return cleanups ? { unwraps, cleanups } : null;
|
|
213
|
+
}
|
|
115
214
|
exports.noUselessFragment = (0, createRule_1.createRule)({
|
|
116
215
|
name: 'no-useless-fragment',
|
|
117
216
|
create(context) {
|
|
217
|
+
const sourceCode = context.sourceCode;
|
|
218
|
+
/**
|
|
219
|
+
* Long-form fragments are recognized through the element's own scope, so a
|
|
220
|
+
* `Fragment` shadowed by a local component is not mistaken for react's.
|
|
221
|
+
*/
|
|
222
|
+
const isFragmentElement = (element) => (0, reactFragmentBinding_1.isReactFragmentElement)(element, (node) => ASTHelpers_1.ASTHelpers.getScope(context, node));
|
|
223
|
+
/**
|
|
224
|
+
* Fragments held until `Program:exit`. Whether an unwrap strands the import
|
|
225
|
+
* that names the fragment is a whole-file question, and the answer can only
|
|
226
|
+
* be given once every fragment reading that import is known.
|
|
227
|
+
*/
|
|
228
|
+
const violations = [];
|
|
229
|
+
/**
|
|
230
|
+
* Whether ESLint will discard a report, resolved the way ESLint resolves it.
|
|
231
|
+
* A batched fix counts on every unwrap in its batch happening; a suppressed
|
|
232
|
+
* report never fixes, so its fragment — and the reference its tags hold —
|
|
233
|
+
* outlives the pass and must not be counted on.
|
|
234
|
+
*/
|
|
235
|
+
const isSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
236
|
+
/**
|
|
237
|
+
* The single decision every fragment spelling shares. `<>`, `<Fragment>`
|
|
238
|
+
* and `<React.Fragment>` denote the same node, so they are reported and
|
|
239
|
+
* unwrapped identically; splitting the logic per spelling is what let the
|
|
240
|
+
* long forms go unexamined in the first place.
|
|
241
|
+
*/
|
|
242
|
+
const collectWhenUseless = (node) => {
|
|
243
|
+
const meaningfulChildren = node.children.filter((child) => !isFormattingWhitespace(child));
|
|
244
|
+
if (meaningfulChildren.length !== 1) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const [child] = meaningfulChildren;
|
|
248
|
+
/**
|
|
249
|
+
* A fragment whose only child is an expression container — e.g.
|
|
250
|
+
* `<>{portal}</>` — is NOT useless. Unwrapping it to a bare
|
|
251
|
+
* `{portal}` is invalid in statement/return position, and wrapping a
|
|
252
|
+
* single ReactNode expression in a fragment is the idiomatic way to
|
|
253
|
+
* render it. (Mirrors the upstream rule's `allowExpressions`.)
|
|
254
|
+
*/
|
|
255
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Unwrapping is only sound when the child is itself standalone JSX.
|
|
260
|
+
* A text child (`<>hello</>`) would become a bare identifier
|
|
261
|
+
* reference, and a spread child (`<>{...items}</>`) is not a valid
|
|
262
|
+
* expression on its own — both are report-only so the developer
|
|
263
|
+
* chooses how to restructure the surrounding code.
|
|
264
|
+
*/
|
|
265
|
+
const isFixable = child.type === 'JSXElement' || child.type === 'JSXFragment';
|
|
266
|
+
violations.push({
|
|
267
|
+
node,
|
|
268
|
+
childKind: describeChild(child, isFragmentElement),
|
|
269
|
+
replacement: isFixable
|
|
270
|
+
? reindentPromotedChild(sourceCode, node, child)
|
|
271
|
+
: null,
|
|
272
|
+
tags: tagRangesOf(node),
|
|
273
|
+
});
|
|
274
|
+
};
|
|
118
275
|
return {
|
|
119
276
|
JSXFragment(node) {
|
|
120
|
-
|
|
121
|
-
|
|
277
|
+
collectWhenUseless(node);
|
|
278
|
+
},
|
|
279
|
+
JSXElement(node) {
|
|
280
|
+
if (!isFragmentElement(node)) {
|
|
122
281
|
return;
|
|
123
282
|
}
|
|
124
|
-
const [child] = meaningfulChildren;
|
|
125
283
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* render it. (Mirrors the upstream rule's `allowExpressions`.)
|
|
284
|
+
* An attribute is content the shorthand cannot carry: `key` positions
|
|
285
|
+
* the fragment in a sibling list, and unwrapping would move it onto
|
|
286
|
+
* the promoted child, changing reconciliation. Only the long forms can
|
|
287
|
+
* reach this branch, since `<>` admits no attributes at all.
|
|
131
288
|
*/
|
|
132
|
-
if (
|
|
289
|
+
if (node.openingElement.attributes.length > 0) {
|
|
133
290
|
return;
|
|
134
291
|
}
|
|
292
|
+
collectWhenUseless(node);
|
|
293
|
+
},
|
|
294
|
+
/**
|
|
295
|
+
* Emits every held report, each carrying the import cleanup its own
|
|
296
|
+
* unwrap makes necessary.
|
|
297
|
+
*
|
|
298
|
+
* Orphanhood is judged against a single fix's own deletions, never
|
|
299
|
+
* against what sibling reports might also delete: ESLint may discard a
|
|
300
|
+
* sibling, and the fragment it was going to unwrap then keeps the import
|
|
301
|
+
* alive.
|
|
302
|
+
*/
|
|
303
|
+
'Program:exit'() {
|
|
304
|
+
if (violations.length === 0)
|
|
305
|
+
return;
|
|
135
306
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
307
|
+
* A fragment nested inside another fragment being unwrapped is left out
|
|
308
|
+
* of the batching. Its tags are carried into the outer fragment's
|
|
309
|
+
* replacement text verbatim, so its reference SURVIVES that fix — and
|
|
310
|
+
* the two replacements would overlap besides. The outer unwrap then
|
|
311
|
+
* finds the import still in use and leaves it, and the next `--fix`
|
|
312
|
+
* pass unwraps what is by then a lone fragment and takes the import
|
|
313
|
+
* with it.
|
|
141
314
|
*/
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
node,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
315
|
+
const unwrapping = violations.filter((violation) => violation.replacement !== null && !isSuppressed(violation.node));
|
|
316
|
+
const batchable = unwrapping.filter((violation) => !unwrapping.some((other) => other !== violation &&
|
|
317
|
+
contains(other.node.range, violation.node.range)));
|
|
318
|
+
const batchableSet = new Set(batchable);
|
|
319
|
+
const plans = new Map();
|
|
320
|
+
for (const batch of batchViolations(sourceCode, batchable)) {
|
|
321
|
+
const plan = planBatch(sourceCode, batch);
|
|
322
|
+
if (!plan)
|
|
323
|
+
continue;
|
|
324
|
+
for (const violation of batch) {
|
|
325
|
+
plans.set(violation, plan);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const violation of violations) {
|
|
329
|
+
/**
|
|
330
|
+
* A fixable fragment the batching left out fixes on its own. It is
|
|
331
|
+
* nested inside another fragment being unwrapped — so its tags are
|
|
332
|
+
* carried into that fragment's replacement rather than deleted, and
|
|
333
|
+
* strand nothing — or it is suppressed, in which case ESLint discards
|
|
334
|
+
* the report and the fix never runs. A fragment that WAS batched and
|
|
335
|
+
* whose batch declined gets no fix at all, which is the whole point of
|
|
336
|
+
* the decline.
|
|
337
|
+
*/
|
|
338
|
+
const own = violation.replacement !== null && !batchableSet.has(violation)
|
|
339
|
+
? {
|
|
340
|
+
unwraps: [
|
|
341
|
+
{ node: violation.node, text: violation.replacement },
|
|
342
|
+
],
|
|
343
|
+
cleanups: [],
|
|
344
|
+
}
|
|
345
|
+
: null;
|
|
346
|
+
const applied = plans.get(violation) ?? own;
|
|
347
|
+
context.report({
|
|
348
|
+
node: violation.node,
|
|
349
|
+
messageId: 'noUselessFragment',
|
|
350
|
+
data: { childKind: violation.childKind },
|
|
351
|
+
...(applied
|
|
352
|
+
? {
|
|
353
|
+
fix: (fixer) => [
|
|
354
|
+
...applied.unwraps.map((unwrap) => fixer.replaceText(unwrap.node, unwrap.text)),
|
|
355
|
+
...applied.cleanups.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
: {}),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
153
361
|
},
|
|
154
362
|
};
|
|
155
363
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* Whether every declaration of a visible `Fragment` binding is react's Fragment
|
|
4
|
+
* import. A const/let/function/class, a parameter, a namespace or default
|
|
5
|
+
* import, an alias, or a named import from another module all mean the element
|
|
6
|
+
* spelled `<Fragment>` renders something other than react's Fragment — a
|
|
7
|
+
* user-defined component a fragment-keyed rule must leave alone.
|
|
8
|
+
*
|
|
9
|
+
* An unresolved name is not react's Fragment either: nothing in the file states
|
|
10
|
+
* what it renders, so counting it would trade a false negative for a false
|
|
11
|
+
* positive on code the rule cannot see.
|
|
12
|
+
*/
|
|
13
|
+
export declare function bindsReactFragment(variable: TSESLint.Scope.Variable | null): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a JSX element is a react fragment written in long form:
|
|
16
|
+
* `<React.Fragment>` (a member access on the React namespace) or a bare
|
|
17
|
+
* `<Fragment>` whose name resolves to react's Fragment import. The binding is
|
|
18
|
+
* resolved from the element's own scope, so a narrower shadow — a component
|
|
19
|
+
* named `Fragment` declared inside a function — answers false there while a
|
|
20
|
+
* module-level react import answers true elsewhere in the same file.
|
|
21
|
+
*
|
|
22
|
+
* The `React.Fragment` arm does not re-resolve `React`, matching how
|
|
23
|
+
* `prefer-fragment-shorthand` and `prefer-fragment-component` recognise the
|
|
24
|
+
* same spelling: a local object named `React` carrying a `Fragment` property is
|
|
25
|
+
* not a shape worth splitting the rules' answers over.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isReactFragmentElement(element: TSESTree.JSXElement, scopeOf: (node: TSESTree.Node) => TSESLint.Scope.Scope): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isReactFragmentElement = exports.bindsReactFragment = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const ASTHelpers_1 = require("./ASTHelpers");
|
|
6
|
+
const REACT_MODULE = 'react';
|
|
7
|
+
const FRAGMENT_NAME = 'Fragment';
|
|
8
|
+
/**
|
|
9
|
+
* A named specifier that binds `Fragment` under its own name — the only shape
|
|
10
|
+
* that makes a bare `<Fragment>` element resolve to react's Fragment. An alias
|
|
11
|
+
* (`import { Fragment as Frag }`) leaves the name free for something else, and
|
|
12
|
+
* a type-only specifier binds nothing at runtime.
|
|
13
|
+
*/
|
|
14
|
+
function isReactFragmentSpecifier(specifier) {
|
|
15
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
16
|
+
specifier.importKind !== 'type' &&
|
|
17
|
+
specifier.imported.name === FRAGMENT_NAME &&
|
|
18
|
+
specifier.local.name === FRAGMENT_NAME);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether every declaration of a visible `Fragment` binding is react's Fragment
|
|
22
|
+
* import. A const/let/function/class, a parameter, a namespace or default
|
|
23
|
+
* import, an alias, or a named import from another module all mean the element
|
|
24
|
+
* spelled `<Fragment>` renders something other than react's Fragment — a
|
|
25
|
+
* user-defined component a fragment-keyed rule must leave alone.
|
|
26
|
+
*
|
|
27
|
+
* An unresolved name is not react's Fragment either: nothing in the file states
|
|
28
|
+
* what it renders, so counting it would trade a false negative for a false
|
|
29
|
+
* positive on code the rule cannot see.
|
|
30
|
+
*/
|
|
31
|
+
function bindsReactFragment(variable) {
|
|
32
|
+
return (!!variable &&
|
|
33
|
+
variable.defs.length > 0 &&
|
|
34
|
+
variable.defs.every((def) => {
|
|
35
|
+
const specifier = def.node;
|
|
36
|
+
if (!isReactFragmentSpecifier(specifier)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const declaration = specifier.parent;
|
|
40
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
41
|
+
declaration.importKind !== 'type' &&
|
|
42
|
+
declaration.source.value === REACT_MODULE);
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
exports.bindsReactFragment = bindsReactFragment;
|
|
46
|
+
/**
|
|
47
|
+
* Whether a JSX element is a react fragment written in long form:
|
|
48
|
+
* `<React.Fragment>` (a member access on the React namespace) or a bare
|
|
49
|
+
* `<Fragment>` whose name resolves to react's Fragment import. The binding is
|
|
50
|
+
* resolved from the element's own scope, so a narrower shadow — a component
|
|
51
|
+
* named `Fragment` declared inside a function — answers false there while a
|
|
52
|
+
* module-level react import answers true elsewhere in the same file.
|
|
53
|
+
*
|
|
54
|
+
* The `React.Fragment` arm does not re-resolve `React`, matching how
|
|
55
|
+
* `prefer-fragment-shorthand` and `prefer-fragment-component` recognise the
|
|
56
|
+
* same spelling: a local object named `React` carrying a `Fragment` property is
|
|
57
|
+
* not a shape worth splitting the rules' answers over.
|
|
58
|
+
*/
|
|
59
|
+
function isReactFragmentElement(element, scopeOf) {
|
|
60
|
+
const { name } = element.openingElement;
|
|
61
|
+
if (name.type === utils_1.AST_NODE_TYPES.JSXMemberExpression) {
|
|
62
|
+
return (name.object.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
|
|
63
|
+
name.object.name === 'React' &&
|
|
64
|
+
name.property.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
|
|
65
|
+
name.property.name === FRAGMENT_NAME);
|
|
66
|
+
}
|
|
67
|
+
if (name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier ||
|
|
68
|
+
name.name !== FRAGMENT_NAME) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return bindsReactFragment(ASTHelpers_1.ASTHelpers.findVariableInScope(scopeOf(element), FRAGMENT_NAME));
|
|
72
|
+
}
|
|
73
|
+
exports.isReactFragmentElement = isReactFragmentElement;
|
|
74
|
+
//# sourceMappingURL=reactFragmentBinding.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blumintinc/eslint-plugin-blumint",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.195",
|
|
4
4
|
"description": "Custom eslint rules for use within BluMint",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Brodie McGuire",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"js-yaml": "4.3.1",
|
|
101
101
|
"jsonc-eslint-parser": "2.3.0",
|
|
102
102
|
"markdown-eslint-parser": "1.2.1",
|
|
103
|
+
"marked": "15.0.12",
|
|
103
104
|
"npm-run-all": "4.1.5",
|
|
104
105
|
"prettier": "2.7.1",
|
|
105
106
|
"remark-cli": "10.0.1",
|
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.195",
|
|
4
|
+
"date": "2026-08-31T00:29:37.037Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-useless-fragment",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2227
|
|
11
|
+
],
|
|
12
|
+
"summary": "visit JSXElement so long-form fragments are reported (closes #2227)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.194",
|
|
18
|
+
"date": "2026-08-30T09:11:52.116Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-typescript-markdown-code-blocks",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
2220
|
|
25
|
+
],
|
|
26
|
+
"summary": "skip a fence opened on a list marker's line (closes #2220)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.193",
|
|
4
32
|
"date": "2026-08-30T05:32:04.963Z",
|