@blumintinc/eslint-plugin-blumint 1.20.143 → 1.20.145
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-centralized-mock-firestore.js +0 -21
- package/lib/rules/no-always-true-false-conditions.js +80 -1
- package/lib/rules/no-explicit-return-type.js +11 -125
- package/lib/rules/no-redundant-annotation-assertion.js +122 -21
- package/lib/rules/no-undefined-null-passthrough.js +15 -8
- package/lib/utils/arrowAnnotationGap.d.ts +72 -0
- package/lib/utils/arrowAnnotationGap.js +150 -0
- package/lib/utils/restrictedProductions.d.ts +45 -0
- package/lib/utils/restrictedProductions.js +233 -0
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -338,27 +338,6 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
340
|
},
|
|
341
|
-
// Handle dynamic imports
|
|
342
|
-
'AwaitExpression > CallExpression[callee.type="ImportExpression"]'(node) {
|
|
343
|
-
const parent = node.parent;
|
|
344
|
-
if (parent?.type === utils_1.AST_NODE_TYPES.AwaitExpression &&
|
|
345
|
-
parent.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
346
|
-
parent.parent.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
347
|
-
for (const prop of parent.parent.id.properties) {
|
|
348
|
-
if (prop.type === utils_1.AST_NODE_TYPES.Property &&
|
|
349
|
-
prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
350
|
-
prop.key.name === 'mockFirestore') {
|
|
351
|
-
mockFirestoreNodes.add(parent.parent);
|
|
352
|
-
// Track renamed destructured imports
|
|
353
|
-
if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
354
|
-
prop.value.name !== 'mockFirestore') {
|
|
355
|
-
customMockFirestoreNames.add(prop.value.name);
|
|
356
|
-
}
|
|
357
|
-
break;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
},
|
|
362
341
|
// Handle complex object destructuring
|
|
363
342
|
'ObjectPattern > Property > ObjectPattern > Property > ObjectPattern > Property[key.name="mockFirestore"]'(node) {
|
|
364
343
|
let current = node;
|
|
@@ -3,6 +3,81 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noAlwaysTrueFalseConditions = void 0;
|
|
4
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
5
|
const utils_1 = require("@typescript-eslint/utils");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const BREAKABLE = new Set([
|
|
8
|
+
utils_1.AST_NODE_TYPES.WhileStatement,
|
|
9
|
+
utils_1.AST_NODE_TYPES.DoWhileStatement,
|
|
10
|
+
utils_1.AST_NODE_TYPES.ForStatement,
|
|
11
|
+
utils_1.AST_NODE_TYPES.ForInStatement,
|
|
12
|
+
utils_1.AST_NODE_TYPES.ForOfStatement,
|
|
13
|
+
utils_1.AST_NODE_TYPES.SwitchStatement,
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* Whether `body` can leave the loop that owns it.
|
|
17
|
+
*
|
|
18
|
+
* An unlabeled `break` binds to the nearest enclosing loop or switch, so a
|
|
19
|
+
* nested breakable is still walked but its own unlabeled breaks do not count; a
|
|
20
|
+
* labeled one counts when it names this loop. `return` and `throw` leave the
|
|
21
|
+
* loop as well, unless they sit inside a nested function, which has its own.
|
|
22
|
+
*/
|
|
23
|
+
function canExitLoop(body, label) {
|
|
24
|
+
let escapes = false;
|
|
25
|
+
const visit = (node, insideNestedBreakable) => {
|
|
26
|
+
if (escapes)
|
|
27
|
+
return;
|
|
28
|
+
switch (node.type) {
|
|
29
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
30
|
+
case utils_1.AST_NODE_TYPES.FunctionExpression:
|
|
31
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
32
|
+
return;
|
|
33
|
+
case utils_1.AST_NODE_TYPES.BreakStatement:
|
|
34
|
+
if (node.label ? node.label.name === label : !insideNestedBreakable) {
|
|
35
|
+
escapes = true;
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
case utils_1.AST_NODE_TYPES.ReturnStatement:
|
|
39
|
+
case utils_1.AST_NODE_TYPES.ThrowStatement:
|
|
40
|
+
escapes = true;
|
|
41
|
+
return;
|
|
42
|
+
default:
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const nested = insideNestedBreakable || BREAKABLE.has(node.type);
|
|
46
|
+
for (const [key, value] of Object.entries(node)) {
|
|
47
|
+
if (key === 'parent')
|
|
48
|
+
continue;
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (const child of value) {
|
|
51
|
+
if (ASTHelpers_1.ASTHelpers.isNode(child))
|
|
52
|
+
visit(child, nested);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
56
|
+
visit(value, nested);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
visit(body, false);
|
|
61
|
+
return escapes;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `while (true)` and `do … while (true)` around a `break` are how a loop whose
|
|
65
|
+
* exit is only known mid-body is written — cursor pagination being the usual
|
|
66
|
+
* case. The condition is the point, and unlike an `if` it cannot be removed:
|
|
67
|
+
* the only way to satisfy the report is to rewrite the loop as `for (;;)`,
|
|
68
|
+
* which this rule already accepts because it has no test node to check. A
|
|
69
|
+
* literal `true` over a body with no way out is still reported, since that loop
|
|
70
|
+
* really does run forever (#1973).
|
|
71
|
+
*/
|
|
72
|
+
function isDeliberateInfiniteLoop(loop) {
|
|
73
|
+
if (loop.test?.type !== utils_1.AST_NODE_TYPES.Literal || loop.test.value !== true) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const label = loop.parent?.type === utils_1.AST_NODE_TYPES.LabeledStatement
|
|
77
|
+
? loop.parent.label.name
|
|
78
|
+
: null;
|
|
79
|
+
return canExitLoop(loop.body, label);
|
|
80
|
+
}
|
|
6
81
|
exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
7
82
|
name: 'no-always-true-false-conditions',
|
|
8
83
|
meta: {
|
|
@@ -1498,15 +1573,19 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
1498
1573
|
},
|
|
1499
1574
|
// Check while loops
|
|
1500
1575
|
WhileStatement(node) {
|
|
1576
|
+
if (isDeliberateInfiniteLoop(node))
|
|
1577
|
+
return;
|
|
1501
1578
|
checkCondition(node.test);
|
|
1502
1579
|
},
|
|
1503
1580
|
// Check do-while loops
|
|
1504
1581
|
DoWhileStatement(node) {
|
|
1582
|
+
if (isDeliberateInfiniteLoop(node))
|
|
1583
|
+
return;
|
|
1505
1584
|
checkCondition(node.test);
|
|
1506
1585
|
},
|
|
1507
1586
|
// Check for loop conditions
|
|
1508
1587
|
ForStatement(node) {
|
|
1509
|
-
if (node.test) {
|
|
1588
|
+
if (node.test && !isDeliberateInfiniteLoop(node)) {
|
|
1510
1589
|
checkCondition(node.test);
|
|
1511
1590
|
}
|
|
1512
1591
|
},
|
|
@@ -8,6 +8,7 @@ const importRemoval_1 = require("../utils/importRemoval");
|
|
|
8
8
|
const typeDeclarationRemoval_1 = require("../utils/typeDeclarationRemoval");
|
|
9
9
|
const replacementSegments_1 = require("../utils/replacementSegments");
|
|
10
10
|
const lexicalScope_1 = require("../utils/lexicalScope");
|
|
11
|
+
const arrowAnnotationGap_1 = require("../utils/arrowAnnotationGap");
|
|
11
12
|
const defaultOptions = {
|
|
12
13
|
allowRecursiveFunctions: true,
|
|
13
14
|
allowOverloadedFunctions: true,
|
|
@@ -780,25 +781,6 @@ function batchAnnotations(source, candidates) {
|
|
|
780
781
|
});
|
|
781
782
|
return [...batches.values()];
|
|
782
783
|
}
|
|
783
|
-
/**
|
|
784
|
-
* A comment whose meaning is tied to where it sits. Re-emitting one somewhere
|
|
785
|
-
* else retargets it — a disable directive lands on an unrelated line and a
|
|
786
|
-
* `@ts-expect-error` becomes an error of its own — so a removal that would move
|
|
787
|
-
* one is withheld instead.
|
|
788
|
-
*/
|
|
789
|
-
function isPositionalDirective(comment) {
|
|
790
|
-
if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
|
|
791
|
-
return true;
|
|
792
|
-
}
|
|
793
|
-
const value = comment.value.trim();
|
|
794
|
-
return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
|
|
795
|
-
}
|
|
796
|
-
/** The indentation of the line `offset` sits on, for a carried line break. */
|
|
797
|
-
function indentAt(source, offset) {
|
|
798
|
-
const lineStart = source.text.lastIndexOf('\n', offset - 1) + 1;
|
|
799
|
-
const [indent] = /^[ \t]*/.exec(source.text.slice(lineStart, offset)) ?? [''];
|
|
800
|
-
return indent;
|
|
801
|
-
}
|
|
802
784
|
/**
|
|
803
785
|
* Whether the span deletes a whole declaration of the program.
|
|
804
786
|
*
|
|
@@ -827,9 +809,9 @@ function carriedText(source, range) {
|
|
|
827
809
|
.filter((comment) => comment.range[0] >= range[0] && comment.range[1] <= range[1]);
|
|
828
810
|
if (comments.length === 0)
|
|
829
811
|
return '';
|
|
830
|
-
if (comments.some(isPositionalDirective))
|
|
812
|
+
if (comments.some(arrowAnnotationGap_1.isPositionalDirective))
|
|
831
813
|
return null;
|
|
832
|
-
const indent = indentAt(source, range[0]);
|
|
814
|
+
const indent = (0, arrowAnnotationGap_1.indentAt)(source, range[0]);
|
|
833
815
|
const segments = comments.map((comment) => ({
|
|
834
816
|
text: source.text.slice(comment.range[0], comment.range[1]),
|
|
835
817
|
breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
|
|
@@ -846,70 +828,20 @@ function carriedText(source, range) {
|
|
|
846
828
|
: ' ';
|
|
847
829
|
return `${lead}${body}${trail}`;
|
|
848
830
|
}
|
|
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
831
|
/**
|
|
895
832
|
* The edits that strip one annotation, carrying every comment the strip
|
|
896
833
|
* strands rather than deleting it (#1877). `null` withholds the fix, for a
|
|
897
834
|
* comment whose meaning is its position and which cannot stay where it is.
|
|
898
835
|
*
|
|
899
836
|
* An arrow is the one subject whose annotation sits inside a restricted
|
|
900
|
-
* production
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
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.
|
|
837
|
+
* production, so its edits come from the shared planner that answers for that
|
|
838
|
+
* grammar (#1964). The removal span handed to it is the annotation's own
|
|
839
|
+
* range: unlike the planner's other caller, nothing here reaches back over the
|
|
840
|
+
* whitespace ahead of the `:`.
|
|
910
841
|
*
|
|
911
842
|
* Every other subject ends its parameter list at a body or a semicolon, so its
|
|
912
|
-
* stranded comments stay where they were written
|
|
843
|
+
* stranded comments stay where they were written and a deletion that carries
|
|
844
|
+
* them in place is correct.
|
|
913
845
|
*/
|
|
914
846
|
function planAnnotationEdits(source, entry) {
|
|
915
847
|
const range = entry.returnType.range;
|
|
@@ -917,53 +849,7 @@ function planAnnotationEdits(source, entry) {
|
|
|
917
849
|
const carried = carriedText(source, range);
|
|
918
850
|
return carried === null ? null : [{ range, text: carried }];
|
|
919
851
|
}
|
|
920
|
-
|
|
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
|
-
}
|
|
958
|
-
/**
|
|
959
|
-
* ESLint applies a fix whole or not at all, and rejects one whose edits
|
|
960
|
-
* overlap. Two spans planned independently — an annotation and the declaration
|
|
961
|
-
* that strands it — can only overlap if a premise here is wrong, so an overlap
|
|
962
|
-
* withdraws the fix rather than throwing at apply time.
|
|
963
|
-
*/
|
|
964
|
-
function isDisjoint(edits) {
|
|
965
|
-
const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
|
|
966
|
-
return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
|
|
852
|
+
return (0, arrowAnnotationGap_1.planArrowAnnotationEdits)(source, entry.returnType, range);
|
|
967
853
|
}
|
|
968
854
|
/**
|
|
969
855
|
* The edits a single fix makes for `batch`: the annotations themselves plus the
|
|
@@ -1002,7 +888,7 @@ function planRemoval(source, removalSource, batch) {
|
|
|
1002
888
|
return null;
|
|
1003
889
|
edits.push({ range, text: carried });
|
|
1004
890
|
}
|
|
1005
|
-
return isDisjoint(edits) ? edits : null;
|
|
891
|
+
return (0, arrowAnnotationGap_1.isDisjoint)(edits) ? edits : null;
|
|
1006
892
|
}
|
|
1007
893
|
exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
1008
894
|
name: 'no-explicit-return-type',
|
|
@@ -28,6 +28,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
28
28
|
const visitor_keys_1 = require("@typescript-eslint/visitor-keys");
|
|
29
29
|
const ts = __importStar(require("typescript"));
|
|
30
30
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
31
|
+
const arrowAnnotationGap_1 = require("../utils/arrowAnnotationGap");
|
|
31
32
|
const createRule_1 = require("../utils/createRule");
|
|
32
33
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
33
34
|
const importRemoval_1 = require("../utils/importRemoval");
|
|
@@ -174,6 +175,49 @@ function annotationRemovalRange(typeAnnotation, sourceCode) {
|
|
|
174
175
|
function typeText(type, checker) {
|
|
175
176
|
return checker.typeToString(type, undefined, typeFormatFlags());
|
|
176
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Whether the checker failed to resolve `type` to a real one.
|
|
180
|
+
*
|
|
181
|
+
* `@typescript-eslint/parser` hands back parser services even with no
|
|
182
|
+
* `parserOptions.project`, but the program it builds then has no lib, so every
|
|
183
|
+
* type naming a global degrades: array types collapse to one shared anonymous
|
|
184
|
+
* `{}` (no symbol), and generic references such as `Map<K, V>` become the error
|
|
185
|
+
* type. Both are assignable to everything and stringify alike, so two unrelated
|
|
186
|
+
* types compare equal — `string[]` and `number[]` are literally the same object.
|
|
187
|
+
* Answering "no type information" with a report is what made this rule delete
|
|
188
|
+
* annotations and silently change a binding's type (#1972).
|
|
189
|
+
*
|
|
190
|
+
* A genuine `{}`, `{ a: number }` or `() => void` carries a `__type` symbol, so
|
|
191
|
+
* only the degraded forms match here. Under a real `tsconfig` nothing does.
|
|
192
|
+
*/
|
|
193
|
+
function isUnresolvedType(type) {
|
|
194
|
+
const candidate = type;
|
|
195
|
+
if ((type.flags & ts.TypeFlags.Any) !== 0 &&
|
|
196
|
+
candidate.intrinsicName === 'error') {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
return (candidate.objectFlags !== undefined &&
|
|
200
|
+
(candidate.objectFlags & ts.ObjectFlags.Anonymous) !== 0 &&
|
|
201
|
+
!type.symbol);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Whitespace inside a type is not part of it, so `Map<string, A>` and
|
|
205
|
+
* `Map<string,A>` must compare equal. Spaces between two word characters are
|
|
206
|
+
* kept so `keyof A` cannot collapse onto a type named `keyofA`.
|
|
207
|
+
*/
|
|
208
|
+
function normalizeTypeSpelling(text) {
|
|
209
|
+
return text
|
|
210
|
+
.replace(/\s+/g, ' ')
|
|
211
|
+
.replace(/\s*([<>,[\]()|&{}:;?])\s*/g, '$1')
|
|
212
|
+
.trim();
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The comparison form drops spacing the developer chose, so a type spanning
|
|
216
|
+
* several lines is flattened for the message but otherwise quoted as written.
|
|
217
|
+
*/
|
|
218
|
+
function displayTypeSpelling(text) {
|
|
219
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
220
|
+
}
|
|
177
221
|
function unwrapAlias(type, checker) {
|
|
178
222
|
const aliasSymbol = type
|
|
179
223
|
.aliasSymbol;
|
|
@@ -334,8 +378,13 @@ function doTypeTextsMatch(representations) {
|
|
|
334
378
|
annotationCanonical === assertionCanonical ||
|
|
335
379
|
annotationStructural === assertionStructural);
|
|
336
380
|
}
|
|
337
|
-
|
|
338
|
-
|
|
381
|
+
/**
|
|
382
|
+
* The message names the type the developer is looking at, so it is the
|
|
383
|
+
* annotation's own spelling rather than `typeToString`, which renders
|
|
384
|
+
* `ValidResult[]` as `Array<ValidResult>` and an unresolved type as `{}`.
|
|
385
|
+
*/
|
|
386
|
+
function selectMatchingTypeRepresentation(annotationSpelling) {
|
|
387
|
+
return annotationSpelling;
|
|
339
388
|
}
|
|
340
389
|
/**
|
|
341
390
|
* Checks if two types are effectively equal for the purpose of identifying redundant assertions.
|
|
@@ -362,16 +411,32 @@ function areTypesEffectivelyEqual(annotationType, assertionType, representations
|
|
|
362
411
|
* @param services The parser services.
|
|
363
412
|
* @returns The matching type string if the types are effectively equal, null otherwise.
|
|
364
413
|
*/
|
|
365
|
-
function haveMatchingTypes(annotation, assertion, checker, services) {
|
|
414
|
+
function haveMatchingTypes(annotation, assertion, checker, services, sourceCode) {
|
|
366
415
|
const annotationType = getComparableType(annotation, checker, services);
|
|
367
416
|
const assertionType = getComparableType(assertion, checker, services);
|
|
368
417
|
if (!annotationType || !assertionType)
|
|
369
418
|
return null;
|
|
419
|
+
const annotationText = sourceCode.getText(annotation);
|
|
420
|
+
const annotationSpelling = normalizeTypeSpelling(annotationText);
|
|
421
|
+
const reportedType = displayTypeSpelling(annotationText);
|
|
422
|
+
/**
|
|
423
|
+
* With either side unresolved the checker cannot separate "same type" from
|
|
424
|
+
* "no type information", so fall back to how the two are written. Identical
|
|
425
|
+
* spellings in one scope denote one type, which keeps every genuinely
|
|
426
|
+
* redundant pair reportable while the mismatched pairs that motivated this
|
|
427
|
+
* fallback — `string[]` against `number[]` — no longer match.
|
|
428
|
+
*/
|
|
429
|
+
if (isUnresolvedType(annotationType) || isUnresolvedType(assertionType)) {
|
|
430
|
+
const assertionSpelling = normalizeTypeSpelling(sourceCode.getText(assertion));
|
|
431
|
+
return annotationSpelling === assertionSpelling
|
|
432
|
+
? selectMatchingTypeRepresentation(reportedType)
|
|
433
|
+
: null;
|
|
434
|
+
}
|
|
370
435
|
const representations = getTypeRepresentations(annotationType, assertionType, checker);
|
|
371
436
|
if (!areTypesEffectivelyEqual(annotationType, assertionType, representations, checker)) {
|
|
372
437
|
return null;
|
|
373
438
|
}
|
|
374
|
-
return selectMatchingTypeRepresentation(
|
|
439
|
+
return selectMatchingTypeRepresentation(reportedType);
|
|
375
440
|
}
|
|
376
441
|
function getReturnAssertionSite(node) {
|
|
377
442
|
const value = node.type === utils_1.AST_NODE_TYPES.MethodDefinition ? node.value : node;
|
|
@@ -864,14 +929,15 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
|
|
|
864
929
|
* trading an unused import for a dangling type.
|
|
865
930
|
*/
|
|
866
931
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
867
|
-
function collectIfRedundant(annotation, assertion, reportNode, fixerTarget) {
|
|
868
|
-
const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices);
|
|
932
|
+
function collectIfRedundant(annotation, assertion, reportNode, fixerTarget, arrowReturnType) {
|
|
933
|
+
const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices, sourceCode);
|
|
869
934
|
if (!matchingType)
|
|
870
935
|
return null;
|
|
871
936
|
const site = {
|
|
872
937
|
reportNode,
|
|
873
938
|
removal: annotationRemovalRange(fixerTarget, sourceCode),
|
|
874
939
|
matchingType,
|
|
940
|
+
arrowReturnType,
|
|
875
941
|
};
|
|
876
942
|
sites.push(site);
|
|
877
943
|
return site;
|
|
@@ -890,25 +956,54 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
|
|
|
890
956
|
// Whether the annotation is load-bearing is decided at `Program:exit`:
|
|
891
957
|
// the cycle can run through a function elsewhere in the file, and every
|
|
892
958
|
// annotation in it goes in the same batched fix.
|
|
893
|
-
const site = collectIfRedundant(annotation, assertionSite.assertion, reportNode, annotation
|
|
959
|
+
const site = collectIfRedundant(annotation, assertionSite.assertion, reportNode, annotation, node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression
|
|
960
|
+
? annotation
|
|
961
|
+
: undefined);
|
|
894
962
|
if (site)
|
|
895
963
|
returnCandidates.push({ site, owners, references });
|
|
896
964
|
}
|
|
965
|
+
/**
|
|
966
|
+
* The text a site's removal writes in place of the annotation.
|
|
967
|
+
*
|
|
968
|
+
* An arrow's annotation is the one that cannot simply be deleted: its slice
|
|
969
|
+
* sits inside a restricted production, so a comment left in the gap — or
|
|
970
|
+
* stranded there by the deletion — turns the output into a SyntaxError that
|
|
971
|
+
* only a compiler reports (#1969). Every other subject ends its signature at
|
|
972
|
+
* a body or a separator, so its slice is deleted exactly as before.
|
|
973
|
+
*/
|
|
974
|
+
function planEdits(site) {
|
|
975
|
+
if (!site.arrowReturnType) {
|
|
976
|
+
return [{ range: site.removal, text: '' }];
|
|
977
|
+
}
|
|
978
|
+
return (0, arrowAnnotationGap_1.planArrowAnnotationEdits)(sourceCode, site.arrowReturnType, site.removal);
|
|
979
|
+
}
|
|
897
980
|
/**
|
|
898
981
|
* The sites whose fixes actually ship. A site is excluded when its report
|
|
899
|
-
* will be suppressed,
|
|
900
|
-
* cannot rewrite — a local alias, an interface, a type parameter
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
982
|
+
* will be suppressed, when its own removal orphans something the helper
|
|
983
|
+
* cannot rewrite — a local alias, an interface, a type parameter — or when
|
|
984
|
+
* its edits cannot be planned without moving a comment whose meaning is its
|
|
985
|
+
* position. Deleting a declaration is a materially riskier edit than
|
|
986
|
+
* dropping an import specifier, and the author is better placed to decide
|
|
987
|
+
* whether the type should go or be used elsewhere.
|
|
904
988
|
*
|
|
905
989
|
* Screening individually before batching keeps one unfixable site from
|
|
906
990
|
* vetoing the rest: orphanhood grows monotonically with the removed set, so
|
|
907
991
|
* a site that cannot be planned alone can only ever poison the batch.
|
|
908
992
|
*/
|
|
909
993
|
function selectFixableSites(candidates) {
|
|
910
|
-
|
|
911
|
-
|
|
994
|
+
const fixable = [];
|
|
995
|
+
for (const site of candidates) {
|
|
996
|
+
if (isReportSuppressed(site.reportNode))
|
|
997
|
+
continue;
|
|
998
|
+
if ((0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [site.removal]) === null) {
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const edits = planEdits(site);
|
|
1002
|
+
if (edits === null)
|
|
1003
|
+
continue;
|
|
1004
|
+
fixable.push({ site, edits });
|
|
1005
|
+
}
|
|
1006
|
+
return fixable;
|
|
912
1007
|
}
|
|
913
1008
|
/**
|
|
914
1009
|
* The file's inference graph, built only where a return annotation is at
|
|
@@ -936,27 +1031,33 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
|
|
|
936
1031
|
if (reportable.length === 0)
|
|
937
1032
|
return;
|
|
938
1033
|
const fixable = selectFixableSites(reportable);
|
|
939
|
-
const removals = fixable.map((
|
|
1034
|
+
const removals = fixable.map((entry) => entry.site.removal);
|
|
940
1035
|
// One plan over every surviving removal: an import referenced solely by
|
|
941
1036
|
// annotations that all go in this pass is orphaned by their union, even
|
|
942
1037
|
// though no single one of them orphans it.
|
|
943
1038
|
const importRanges = removals.length > 0
|
|
944
1039
|
? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, removals)
|
|
945
1040
|
: null;
|
|
1041
|
+
// The batch rewrites some spans rather than deleting them, and ships as
|
|
1042
|
+
// one fix: ESLint rejects a fix whose edits overlap, so an overlap
|
|
1043
|
+
// withdraws the fix instead of throwing at apply time. Only a wrong
|
|
1044
|
+
// premise can produce one — an annotation's gap and an import
|
|
1045
|
+
// declaration are disjoint regions of the file.
|
|
1046
|
+
const edits = [
|
|
1047
|
+
...fixable.flatMap((entry) => entry.edits),
|
|
1048
|
+
...(importRanges ?? []).map((range) => ({ range, text: '' })),
|
|
1049
|
+
];
|
|
946
1050
|
// The whole batch ships as one fix, so no removal can land without the
|
|
947
1051
|
// others that the import's orphanhood was judged against. The rest
|
|
948
1052
|
// report without a fixer; the carrier's pass already resolves them.
|
|
949
|
-
const carrier = importRanges ? fixable[0] : undefined;
|
|
1053
|
+
const carrier = importRanges && (0, arrowAnnotationGap_1.isDisjoint)(edits) ? fixable[0]?.site : undefined;
|
|
950
1054
|
for (const site of reportable) {
|
|
951
1055
|
context.report({
|
|
952
1056
|
node: site.reportNode,
|
|
953
1057
|
messageId: 'redundantAnnotationAndAssertion',
|
|
954
1058
|
data: { type: site.matchingType },
|
|
955
|
-
fix: site === carrier
|
|
956
|
-
? (fixer) => [
|
|
957
|
-
...removals.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
958
|
-
...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
959
|
-
]
|
|
1059
|
+
fix: site === carrier
|
|
1060
|
+
? (fixer) => edits.map((edit) => fixer.replaceTextRange([edit.range[0], edit.range[1]], edit.text))
|
|
960
1061
|
: null,
|
|
961
1062
|
});
|
|
962
1063
|
}
|
|
@@ -265,6 +265,21 @@ function checkImplicitReturn(node, context) {
|
|
|
265
265
|
}
|
|
266
266
|
if (!paramName)
|
|
267
267
|
return;
|
|
268
|
+
/**
|
|
269
|
+
* A body that is the parameter itself — `(value) => value` — is not reported.
|
|
270
|
+
* What this rule looks for is a function that ANSWERS a nullish argument by
|
|
271
|
+
* handing the absence back: a guard, an `&&`, a ternary whose alternate is
|
|
272
|
+
* nullish. The identity function has no nullish-specific behaviour at all, so
|
|
273
|
+
* the prescribed remedy (validate up front, or return a concrete fallback)
|
|
274
|
+
* does not apply to it — least of all to `items.filter((x) => x)`, the
|
|
275
|
+
* standard truthiness filter, or to a generic `identity` helper.
|
|
276
|
+
*
|
|
277
|
+
* Every other spelling of that same function — a block-bodied arrow, a
|
|
278
|
+
* declaration, a function expression — was already accepted, so reporting
|
|
279
|
+
* only the implicit-return form made the verdict turn on body spelling. The
|
|
280
|
+
* test suite recorded that asymmetry as a deferred question and asked that a
|
|
281
|
+
* carve-out settle both spellings at once; this is that carve-out (#1974).
|
|
282
|
+
*/
|
|
268
283
|
if (isNullishPassthroughExpression(node.body, paramName)) {
|
|
269
284
|
context.report({
|
|
270
285
|
node,
|
|
@@ -272,14 +287,6 @@ function checkImplicitReturn(node, context) {
|
|
|
272
287
|
data: { paramName },
|
|
273
288
|
});
|
|
274
289
|
}
|
|
275
|
-
else if (node.body.type === 'Identifier' && node.body.name === paramName) {
|
|
276
|
-
// Check for (param) => param
|
|
277
|
-
context.report({
|
|
278
|
-
node,
|
|
279
|
-
messageId: 'unexpected',
|
|
280
|
-
data: { paramName },
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
290
|
}
|
|
284
291
|
/**
|
|
285
292
|
* Check if an expression is testing if a parameter is null or undefined
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
import { TextRange } from './importRemoval';
|
|
3
|
+
/**
|
|
4
|
+
* The span between an arrow function's parameter list and its `=>` is the one
|
|
5
|
+
* place a return-type annotation sits inside a restricted production:
|
|
6
|
+
* `ArrowParameters [no LineTerminator here] =>` forbids a line terminator
|
|
7
|
+
* there, and the syntactic grammar counts a block comment carrying a line
|
|
8
|
+
* terminator AS one. Stripping the annotation and leaving such a comment behind
|
|
9
|
+
* therefore emits a hard SyntaxError (TS1200 / V8 `Unexpected token '=>'`) —
|
|
10
|
+
* which `@typescript-eslint/parser` accepts, so no reparse-based guard sees it
|
|
11
|
+
* (#1964, #1969).
|
|
12
|
+
*
|
|
13
|
+
* Every other subject an annotation can hang off — a function declaration, a
|
|
14
|
+
* method, a class property with a body, a plain binding — ends its signature at
|
|
15
|
+
* a body or a separator, so nothing about the comments around it is restricted
|
|
16
|
+
* and a plain deletion stays correct.
|
|
17
|
+
*/
|
|
18
|
+
/** One span of a fix, and the text that replaces it. */
|
|
19
|
+
export type Edit = {
|
|
20
|
+
range: TextRange;
|
|
21
|
+
text: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A comment whose meaning is tied to where it sits. Re-emitting one somewhere
|
|
25
|
+
* else retargets it — a disable directive lands on an unrelated line and a
|
|
26
|
+
* `@ts-expect-error` becomes an error of its own — so a rewrite that would move
|
|
27
|
+
* one is withheld instead.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isPositionalDirective(comment: TSESTree.Comment): boolean;
|
|
30
|
+
/** The indentation of the line `offset` sits on, for a carried line break. */
|
|
31
|
+
export declare function indentAt(source: TSESLint.SourceCode, offset: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* The span an arrow's return annotation occupies between the parameter list and
|
|
34
|
+
* the `=>`, together with that arrow token.
|
|
35
|
+
*
|
|
36
|
+
* The span holds the annotation, whitespace and comments and nothing else,
|
|
37
|
+
* which is what makes it safe to rewrite wholesale: no binding reference can
|
|
38
|
+
* hide in it beyond the ones the annotation itself names.
|
|
39
|
+
*/
|
|
40
|
+
export declare function arrowAnnotationGap(source: TSESLint.SourceCode, returnType: TSESTree.TSTypeAnnotation): {
|
|
41
|
+
gap: TextRange;
|
|
42
|
+
arrow: TSESTree.Token;
|
|
43
|
+
} | null;
|
|
44
|
+
/**
|
|
45
|
+
* The edits that strip an arrow function's return annotation without leaving a
|
|
46
|
+
* line terminator in the restricted gap, carrying every comment the strip
|
|
47
|
+
* strands rather than deleting it (#1877).
|
|
48
|
+
*
|
|
49
|
+
* `removal` is the span the calling rule would otherwise delete: it covers the
|
|
50
|
+
* annotation and may reach further back over the horizontal whitespace ahead of
|
|
51
|
+
* the `:`. It must lie inside the gap, which it does for any annotation the
|
|
52
|
+
* caller located on the arrow itself.
|
|
53
|
+
*
|
|
54
|
+
* A comment that must own a line is re-emitted past the `=>`, the nearest
|
|
55
|
+
* position outside the restricted gap that cannot itself begin one; hoisting it
|
|
56
|
+
* above the enclosing line would anchor an insertion at a column zero that may
|
|
57
|
+
* sit inside a template literal or JSX text, where the comment would become
|
|
58
|
+
* content rather than code. A comment that trips no restricted production stays
|
|
59
|
+
* exactly where it was written, since moving comments gratuitously is its own
|
|
60
|
+
* regression.
|
|
61
|
+
*
|
|
62
|
+
* `null` withholds the fix, for a comment whose meaning is its position and
|
|
63
|
+
* which cannot stay where it is.
|
|
64
|
+
*/
|
|
65
|
+
export declare function planArrowAnnotationEdits(source: TSESLint.SourceCode, returnType: TSESTree.TSTypeAnnotation, removal: TextRange): Edit[] | null;
|
|
66
|
+
/**
|
|
67
|
+
* ESLint applies a fix whole or not at all, and rejects one whose edits
|
|
68
|
+
* overlap. Spans planned independently — several annotations, and the bindings
|
|
69
|
+
* their removal orphans — can only overlap if a premise behind them is wrong,
|
|
70
|
+
* so an overlap withdraws the fix rather than throwing at apply time.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isDisjoint(edits: readonly Edit[]): boolean;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isDisjoint = exports.planArrowAnnotationEdits = exports.arrowAnnotationGap = exports.indentAt = exports.isPositionalDirective = void 0;
|
|
4
|
+
const disableDirectives_1 = require("./disableDirectives");
|
|
5
|
+
const replacementSegments_1 = require("./replacementSegments");
|
|
6
|
+
/** Every character the syntactic grammar counts as a LineTerminator. */
|
|
7
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
8
|
+
function containsRange(outer, inner) {
|
|
9
|
+
return inner[0] >= outer[0] && inner[1] <= outer[1];
|
|
10
|
+
}
|
|
11
|
+
const textOf = (source, range) => source.text.slice(range[0], range[1]);
|
|
12
|
+
/**
|
|
13
|
+
* A comment whose meaning is tied to where it sits. Re-emitting one somewhere
|
|
14
|
+
* else retargets it — a disable directive lands on an unrelated line and a
|
|
15
|
+
* `@ts-expect-error` becomes an error of its own — so a rewrite that would move
|
|
16
|
+
* one is withheld instead.
|
|
17
|
+
*/
|
|
18
|
+
function isPositionalDirective(comment) {
|
|
19
|
+
if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const value = comment.value.trim();
|
|
23
|
+
return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
|
|
24
|
+
}
|
|
25
|
+
exports.isPositionalDirective = isPositionalDirective;
|
|
26
|
+
/** The indentation of the line `offset` sits on, for a carried line break. */
|
|
27
|
+
function indentAt(source, offset) {
|
|
28
|
+
const lineStart = source.text.lastIndexOf('\n', offset - 1) + 1;
|
|
29
|
+
const [indent] = /^[ \t]*/.exec(source.text.slice(lineStart, offset)) ?? [''];
|
|
30
|
+
return indent;
|
|
31
|
+
}
|
|
32
|
+
exports.indentAt = indentAt;
|
|
33
|
+
/**
|
|
34
|
+
* The span an arrow's return annotation occupies between the parameter list and
|
|
35
|
+
* the `=>`, together with that arrow token.
|
|
36
|
+
*
|
|
37
|
+
* The span holds the annotation, whitespace and comments and nothing else,
|
|
38
|
+
* which is what makes it safe to rewrite wholesale: no binding reference can
|
|
39
|
+
* hide in it beyond the ones the annotation itself names.
|
|
40
|
+
*/
|
|
41
|
+
function arrowAnnotationGap(source, returnType) {
|
|
42
|
+
const parametersEnd = source.getTokenBefore(returnType);
|
|
43
|
+
const arrow = source.getTokenAfter(returnType, {
|
|
44
|
+
filter: (token) => token.value === '=>',
|
|
45
|
+
});
|
|
46
|
+
if (!parametersEnd || !arrow)
|
|
47
|
+
return null;
|
|
48
|
+
const gap = [parametersEnd.range[1], arrow.range[0]];
|
|
49
|
+
return containsRange(gap, returnType.range) ? { gap, arrow } : null;
|
|
50
|
+
}
|
|
51
|
+
exports.arrowAnnotationGap = arrowAnnotationGap;
|
|
52
|
+
/**
|
|
53
|
+
* Re-emits `comments` on the far side of the arrow, where a line terminator is
|
|
54
|
+
* inert, consuming the horizontal whitespace the arrow already had after it so
|
|
55
|
+
* the body keeps a single separator.
|
|
56
|
+
*/
|
|
57
|
+
function hoistPastArrow(source, arrow, comments) {
|
|
58
|
+
const indent = indentAt(source, arrow.range[0]);
|
|
59
|
+
const trailingText = source.text.slice(arrow.range[1]);
|
|
60
|
+
const [spacing] = /^[ \t]*/.exec(trailingText) ?? [''];
|
|
61
|
+
const body = (0, replacementSegments_1.joinSegmentBody)(comments.map((comment) => ({
|
|
62
|
+
text: textOf(source, comment.range),
|
|
63
|
+
breakAfter: true,
|
|
64
|
+
})), indent);
|
|
65
|
+
const rest = trailingText.slice(spacing.length);
|
|
66
|
+
const separator = LINE_TERMINATOR.test(rest.charAt(0))
|
|
67
|
+
? ''
|
|
68
|
+
: (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
|
|
69
|
+
? `\n${indent}`
|
|
70
|
+
: ' ';
|
|
71
|
+
return {
|
|
72
|
+
range: [arrow.range[1], arrow.range[1] + spacing.length],
|
|
73
|
+
text: ` ${body}${separator}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The edits that strip an arrow function's return annotation without leaving a
|
|
78
|
+
* line terminator in the restricted gap, carrying every comment the strip
|
|
79
|
+
* strands rather than deleting it (#1877).
|
|
80
|
+
*
|
|
81
|
+
* `removal` is the span the calling rule would otherwise delete: it covers the
|
|
82
|
+
* annotation and may reach further back over the horizontal whitespace ahead of
|
|
83
|
+
* the `:`. It must lie inside the gap, which it does for any annotation the
|
|
84
|
+
* caller located on the arrow itself.
|
|
85
|
+
*
|
|
86
|
+
* A comment that must own a line is re-emitted past the `=>`, the nearest
|
|
87
|
+
* position outside the restricted gap that cannot itself begin one; hoisting it
|
|
88
|
+
* above the enclosing line would anchor an insertion at a column zero that may
|
|
89
|
+
* sit inside a template literal or JSX text, where the comment would become
|
|
90
|
+
* content rather than code. A comment that trips no restricted production stays
|
|
91
|
+
* exactly where it was written, since moving comments gratuitously is its own
|
|
92
|
+
* regression.
|
|
93
|
+
*
|
|
94
|
+
* `null` withholds the fix, for a comment whose meaning is its position and
|
|
95
|
+
* which cannot stay where it is.
|
|
96
|
+
*/
|
|
97
|
+
function planArrowAnnotationEdits(source, returnType, removal) {
|
|
98
|
+
const gapInfo = arrowAnnotationGap(source, returnType);
|
|
99
|
+
if (!gapInfo)
|
|
100
|
+
return null;
|
|
101
|
+
const { gap, arrow } = gapInfo;
|
|
102
|
+
if (!containsRange(gap, removal))
|
|
103
|
+
return null;
|
|
104
|
+
const comments = source
|
|
105
|
+
.getAllComments()
|
|
106
|
+
.filter((comment) => containsRange(gap, comment.range));
|
|
107
|
+
const stranded = comments.filter((comment) => containsRange(removal, comment.range));
|
|
108
|
+
// What the plain deletion would leave between the parameters and the arrow.
|
|
109
|
+
// A comment left there contributes its own text, so a line comment or a
|
|
110
|
+
// multi-line block comment shows up here as the line terminator it is.
|
|
111
|
+
const residue = `${textOf(source, [gap[0], removal[0]])}${textOf(source, [
|
|
112
|
+
removal[1],
|
|
113
|
+
gap[1],
|
|
114
|
+
])}`;
|
|
115
|
+
// The plain deletion is kept wherever it already lands a legal gap and
|
|
116
|
+
// strands nothing, so no output that survives today moves by a byte.
|
|
117
|
+
if (stranded.length === 0 && !LINE_TERMINATOR.test(residue)) {
|
|
118
|
+
return [{ range: removal, text: '' }];
|
|
119
|
+
}
|
|
120
|
+
// Rewriting the gap collapses the lines it spanned, which moves the line a
|
|
121
|
+
// directive inside it points at, so the whole fix is withheld rather than
|
|
122
|
+
// retargeting one. The gap a directive can share with nothing else is left
|
|
123
|
+
// untouched by the branch above.
|
|
124
|
+
if (comments.some(isPositionalDirective))
|
|
125
|
+
return null;
|
|
126
|
+
const hoisted = comments.filter(replacementSegments_1.requiresOwnLine);
|
|
127
|
+
const inline = comments
|
|
128
|
+
.filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
|
|
129
|
+
.map((comment) => textOf(source, comment.range));
|
|
130
|
+
const edits = [
|
|
131
|
+
{ range: gap, text: inline.length === 0 ? ' ' : ` ${inline.join(' ')} ` },
|
|
132
|
+
];
|
|
133
|
+
if (hoisted.length > 0) {
|
|
134
|
+
edits.push(hoistPastArrow(source, arrow, hoisted));
|
|
135
|
+
}
|
|
136
|
+
return edits;
|
|
137
|
+
}
|
|
138
|
+
exports.planArrowAnnotationEdits = planArrowAnnotationEdits;
|
|
139
|
+
/**
|
|
140
|
+
* ESLint applies a fix whole or not at all, and rejects one whose edits
|
|
141
|
+
* overlap. Spans planned independently — several annotations, and the bindings
|
|
142
|
+
* their removal orphans — can only overlap if a premise behind them is wrong,
|
|
143
|
+
* so an overlap withdraws the fix rather than throwing at apply time.
|
|
144
|
+
*/
|
|
145
|
+
function isDisjoint(edits) {
|
|
146
|
+
const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
|
|
147
|
+
return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
|
|
148
|
+
}
|
|
149
|
+
exports.isDisjoint = isDisjoint;
|
|
150
|
+
//# sourceMappingURL=arrowAnnotationGap.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type RestrictedProduction = 'arrow' | 'throw';
|
|
2
|
+
export type RestrictedBreach = {
|
|
3
|
+
production: RestrictedProduction;
|
|
4
|
+
/** 1-indexed line of the token that closes the gap. */
|
|
5
|
+
line: number;
|
|
6
|
+
/** The offending text between the two tokens, comments included. */
|
|
7
|
+
gap: string;
|
|
8
|
+
};
|
|
9
|
+
type Token = {
|
|
10
|
+
type: string;
|
|
11
|
+
value: string;
|
|
12
|
+
range: [number, number];
|
|
13
|
+
};
|
|
14
|
+
type Node = {
|
|
15
|
+
type: string;
|
|
16
|
+
range: [number, number];
|
|
17
|
+
} & Record<string, unknown>;
|
|
18
|
+
type ParsedSource = {
|
|
19
|
+
ast: Node;
|
|
20
|
+
tokens: Token[];
|
|
21
|
+
} | null;
|
|
22
|
+
/**
|
|
23
|
+
* `.ts` and `.tsx` are not ordered by permissiveness — only `.ts` accepts
|
|
24
|
+
* `<T>expr` and only `.tsx` accepts JSX — so a snippet is tried both ways rather
|
|
25
|
+
* than parsed under a guessed extension. A snippet that parses under neither is
|
|
26
|
+
* `null`: unparsable text is `fixture-corpus-parsability`'s axis, and reporting
|
|
27
|
+
* it here would double-count it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseForRestrictedProductions(code: string): ParsedSource;
|
|
30
|
+
/**
|
|
31
|
+
* Every restricted-production breach in `code`, or `null` when it does not parse
|
|
32
|
+
* at all.
|
|
33
|
+
*
|
|
34
|
+
* The gap is measured between TOKENS, so the text it spans is whitespace and
|
|
35
|
+
* comments and nothing else. That is what makes a block comment carrying a line
|
|
36
|
+
* terminator indistinguishable from a raw newline here — which is the whole
|
|
37
|
+
* point, since it is indistinguishable to the grammar too.
|
|
38
|
+
*/
|
|
39
|
+
export declare function restrictedProductionBreaches(code: string): RestrictedBreach[] | null;
|
|
40
|
+
/**
|
|
41
|
+
* The non-comment token stream, used to prove a planted comment changed nothing
|
|
42
|
+
* but comments. `null` when the text does not parse.
|
|
43
|
+
*/
|
|
44
|
+
export declare function tokenSignatureOf(code: string): string | null;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.tokenSignatureOf = exports.restrictedProductionBreaches = exports.parseForRestrictedProductions = void 0;
|
|
27
|
+
const tsParser = __importStar(require("@typescript-eslint/parser"));
|
|
28
|
+
/**
|
|
29
|
+
* Restricted productions: the places the ECMAScript grammar forbids a
|
|
30
|
+
* LineTerminator, and where `@typescript-eslint/parser` accepts one anyway.
|
|
31
|
+
*
|
|
32
|
+
* A fixer that leaves — or carries — a line break into one of these gaps emits
|
|
33
|
+
* text no engine will run, and NOTHING else in this repo's pipeline says so.
|
|
34
|
+
* Every parse-based guard (`fixture-corpus-parsability`, `fix-fixpoint-closure`'s
|
|
35
|
+
* fatal check, `fix-orphan-binding-closure`'s `message.fatal` gate, the agora
|
|
36
|
+
* `fix: true` sweep) reads the broken text as clean, because the parser they all
|
|
37
|
+
* share is the one that accepts it. #1964 shipped through every one of them.
|
|
38
|
+
*
|
|
39
|
+
* WHICH productions belong here is MEASURED, not copied from the spec. The
|
|
40
|
+
* grammar lists seven; only two are detectable here, because for the rest
|
|
41
|
+
* `@typescript-eslint/parser` already agrees with V8:
|
|
42
|
+
*
|
|
43
|
+
* | production | parser | V8 | detectable here |
|
|
44
|
+
* | -------------------------- | ------- | ------- | ------------------- |
|
|
45
|
+
* | `ArrowParameters [] =>` | accepts | rejects | YES |
|
|
46
|
+
* | `throw []` | accepts | rejects | YES |
|
|
47
|
+
* | `async [] (params) =>` | rejects | rejects | no — parser sees it |
|
|
48
|
+
* | `async [] method()` | rejects | rejects | no — parser sees it |
|
|
49
|
+
* | `yield [] *` | rejects | rejects | no — parser sees it |
|
|
50
|
+
* | postfix `++` / `--` | rejects | rejects | no — parser sees it |
|
|
51
|
+
* | `return` / `yield` / label | ASI | ASI | no — both insert `;` |
|
|
52
|
+
*
|
|
53
|
+
* The last row is the one worth stating plainly: for `return`, `yield`,
|
|
54
|
+
* `break`/`continue` with a label and `async function`, the parser applies
|
|
55
|
+
* automatic semicolon insertion exactly as V8 does, so the two never disagree
|
|
56
|
+
* and there is no divergence to detect. The rows the parser rejects are already
|
|
57
|
+
* fatal to every parse-based guard, so they need no help from this module.
|
|
58
|
+
*
|
|
59
|
+
* `src/tests/restricted-production-closure.test.ts` re-measures that table on
|
|
60
|
+
* every run: an arm that stops being redundant (a parser upgrade turning a
|
|
61
|
+
* "rejects" into an "accepts") fails there rather than silently going unchecked.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Every character the syntactic grammar counts as a LineTerminator, by code
|
|
65
|
+
* point rather than as a regular expression: U+2028 and U+2029 terminate a line
|
|
66
|
+
* in JavaScript SOURCE too, so a literal holding them cannot be written here
|
|
67
|
+
* without breaking this file.
|
|
68
|
+
*/
|
|
69
|
+
const LINE_TERMINATOR_CODES = new Set([0x0a, 0x0d, 0x2028, 0x2029]);
|
|
70
|
+
const hasLineTerminator = (text) => {
|
|
71
|
+
for (let index = 0; index < text.length; index++) {
|
|
72
|
+
if (LINE_TERMINATOR_CODES.has(text.charCodeAt(index)))
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
const PARSE_OPTIONS = {
|
|
78
|
+
ecmaVersion: 2022,
|
|
79
|
+
sourceType: 'module',
|
|
80
|
+
range: true,
|
|
81
|
+
loc: true,
|
|
82
|
+
comment: true,
|
|
83
|
+
tokens: true,
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* `.ts` and `.tsx` are not ordered by permissiveness — only `.ts` accepts
|
|
87
|
+
* `<T>expr` and only `.tsx` accepts JSX — so a snippet is tried both ways rather
|
|
88
|
+
* than parsed under a guessed extension. A snippet that parses under neither is
|
|
89
|
+
* `null`: unparsable text is `fixture-corpus-parsability`'s axis, and reporting
|
|
90
|
+
* it here would double-count it.
|
|
91
|
+
*/
|
|
92
|
+
function parseForRestrictedProductions(code) {
|
|
93
|
+
for (const jsx of [true, false]) {
|
|
94
|
+
try {
|
|
95
|
+
const ast = tsParser.parse(code, {
|
|
96
|
+
...PARSE_OPTIONS,
|
|
97
|
+
ecmaFeatures: { jsx },
|
|
98
|
+
});
|
|
99
|
+
if (ast.tokens)
|
|
100
|
+
return { ast, tokens: ast.tokens };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
exports.parseForRestrictedProductions = parseForRestrictedProductions;
|
|
109
|
+
function visit(node, callback) {
|
|
110
|
+
if (!node || typeof node !== 'object')
|
|
111
|
+
return;
|
|
112
|
+
const record = node;
|
|
113
|
+
if (typeof record.type === 'string' && Array.isArray(record.range)) {
|
|
114
|
+
callback(record);
|
|
115
|
+
}
|
|
116
|
+
for (const [key, value] of Object.entries(record)) {
|
|
117
|
+
// `parent` is a back-edge on some node shapes and would loop forever.
|
|
118
|
+
if (key === 'parent')
|
|
119
|
+
continue;
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
for (const item of value)
|
|
122
|
+
visit(item, callback);
|
|
123
|
+
}
|
|
124
|
+
else if (value && typeof value === 'object') {
|
|
125
|
+
visit(value, callback);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Index of the last token ending at or before `position`. */
|
|
130
|
+
function lastTokenIndexBefore(tokens, position) {
|
|
131
|
+
let low = 0;
|
|
132
|
+
let high = tokens.length - 1;
|
|
133
|
+
let found = -1;
|
|
134
|
+
while (low <= high) {
|
|
135
|
+
const middle = (low + high) >> 1;
|
|
136
|
+
if (tokens[middle].range[1] <= position) {
|
|
137
|
+
found = middle;
|
|
138
|
+
low = middle + 1;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
high = middle - 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
const lineAt = (code, position) => code.slice(0, position).split('\n').length;
|
|
147
|
+
/**
|
|
148
|
+
* Every restricted-production breach in `code`, or `null` when it does not parse
|
|
149
|
+
* at all.
|
|
150
|
+
*
|
|
151
|
+
* The gap is measured between TOKENS, so the text it spans is whitespace and
|
|
152
|
+
* comments and nothing else. That is what makes a block comment carrying a line
|
|
153
|
+
* terminator indistinguishable from a raw newline here — which is the whole
|
|
154
|
+
* point, since it is indistinguishable to the grammar too.
|
|
155
|
+
*/
|
|
156
|
+
function restrictedProductionBreaches(code) {
|
|
157
|
+
const parsed = parseForRestrictedProductions(code);
|
|
158
|
+
if (!parsed)
|
|
159
|
+
return null;
|
|
160
|
+
const { ast, tokens } = parsed;
|
|
161
|
+
const breaches = [];
|
|
162
|
+
const record = (production, from, to) => {
|
|
163
|
+
const gap = code.slice(from, to);
|
|
164
|
+
if (!hasLineTerminator(gap))
|
|
165
|
+
return;
|
|
166
|
+
breaches.push({ production, line: lineAt(code, to), gap });
|
|
167
|
+
};
|
|
168
|
+
visit(ast, (node) => {
|
|
169
|
+
if (node.type === 'ArrowFunctionExpression') {
|
|
170
|
+
const body = node.body;
|
|
171
|
+
if (!body)
|
|
172
|
+
return;
|
|
173
|
+
/**
|
|
174
|
+
* The arrow token is the LAST `=>` before the body, never the first: a
|
|
175
|
+
* default parameter may itself be an arrow, and its `=>` sits inside this
|
|
176
|
+
* node's range.
|
|
177
|
+
*
|
|
178
|
+
* Taking the token immediately before it is also what makes a TypeScript
|
|
179
|
+
* return annotation fall out correctly. The grammar forbids the break
|
|
180
|
+
* between the SIGNATURE and `=>`, and the annotation is part of the
|
|
181
|
+
* signature — `() \n : T => 1` is legal and `(): T \n => 1` is not, which
|
|
182
|
+
* is exactly the pair this comparison distinguishes.
|
|
183
|
+
*/
|
|
184
|
+
const end = lastTokenIndexBefore(tokens, body.range[0]);
|
|
185
|
+
for (let index = end; index >= 0; index--) {
|
|
186
|
+
if (tokens[index].range[1] <= node.range[0])
|
|
187
|
+
break;
|
|
188
|
+
if (tokens[index].value !== '=>')
|
|
189
|
+
continue;
|
|
190
|
+
if (index > 0) {
|
|
191
|
+
record('arrow', tokens[index - 1].range[1], tokens[index].range[0]);
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (node.type === 'ThrowStatement' && node.argument) {
|
|
198
|
+
// The keyword opens the statement, so it is the token after the last one
|
|
199
|
+
// that ends at or before the statement's start.
|
|
200
|
+
const index = lastTokenIndexBefore(tokens, node.range[0]) + 1;
|
|
201
|
+
const keyword = tokens[index];
|
|
202
|
+
if (!keyword || keyword.value !== 'throw')
|
|
203
|
+
return;
|
|
204
|
+
/**
|
|
205
|
+
* The parser does not reject the breach, but it does RECOVER from it: it
|
|
206
|
+
* emits a zero-width token where the argument should have been and hands
|
|
207
|
+
* back a `ThrowStatement` whose argument is an empty node. Measuring the
|
|
208
|
+
* gap to that phantom would measure nothing at all, so the width filter
|
|
209
|
+
* is what makes this arm detect anything.
|
|
210
|
+
*/
|
|
211
|
+
const next = tokens
|
|
212
|
+
.slice(index + 1)
|
|
213
|
+
.find((token) => token.range[1] > token.range[0]);
|
|
214
|
+
if (!next)
|
|
215
|
+
return;
|
|
216
|
+
record('throw', keyword.range[1], next.range[0]);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
return breaches;
|
|
220
|
+
}
|
|
221
|
+
exports.restrictedProductionBreaches = restrictedProductionBreaches;
|
|
222
|
+
/**
|
|
223
|
+
* The non-comment token stream, used to prove a planted comment changed nothing
|
|
224
|
+
* but comments. `null` when the text does not parse.
|
|
225
|
+
*/
|
|
226
|
+
function tokenSignatureOf(code) {
|
|
227
|
+
const parsed = parseForRestrictedProductions(code);
|
|
228
|
+
if (!parsed)
|
|
229
|
+
return null;
|
|
230
|
+
return parsed.tokens.map((token) => `${token.type} ${token.value}`).join(' ');
|
|
231
|
+
}
|
|
232
|
+
exports.tokenSignatureOf = tokenSignatureOf;
|
|
233
|
+
//# sourceMappingURL=restrictedProductions.js.map
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.145",
|
|
4
|
+
"date": "2026-08-12T16:05:12.174Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-always-true-false-conditions",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1973
|
|
11
|
+
],
|
|
12
|
+
"summary": "accept a literal loop test the body can exit (closes #1973)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-redundant-annotation-assertion",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1972
|
|
19
|
+
],
|
|
20
|
+
"summary": "do not treat unresolved types as identical (closes #1972)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "no-undefined-null-passthrough",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1974
|
|
27
|
+
],
|
|
28
|
+
"summary": "stop reporting the identity function (closes #1974)"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "1.20.144",
|
|
34
|
+
"date": "2026-08-12T12:16:01.495Z",
|
|
35
|
+
"rules": [
|
|
36
|
+
{
|
|
37
|
+
"name": "no-redundant-annotation-assertion",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1969
|
|
41
|
+
],
|
|
42
|
+
"summary": "carry a stranded comment past the arrow (closes #1969)"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "1.20.143",
|
|
4
48
|
"date": "2026-08-12T07:48:25.970Z",
|