@blumintinc/eslint-plugin-blumint 1.20.63 → 1.20.65
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-firestore-doc-ref-generic.js +134 -0
- package/lib/rules/enforce-id-capitalization.js +61 -7
- package/lib/rules/enforce-memoize-async.js +56 -0
- package/lib/rules/enforce-memoize-getters.js +259 -0
- package/lib/rules/no-empty-dependency-use-callbacks.js +74 -1
- package/lib/rules/no-explicit-return-type.d.ts +1 -0
- package/lib/rules/no-explicit-return-type.js +57 -0
- package/lib/rules/use-latest-callback.js +76 -1
- package/package.json +1 -1
- package/release-manifest.json +68 -0
package/lib/index.js
CHANGED
|
@@ -565,6 +565,131 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
565
565
|
}
|
|
566
566
|
return false;
|
|
567
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* `@firebase/rules-unit-testing` hands back the compat (v8) Firestore, whose
|
|
570
|
+
* `.doc()`, `.collection()` and `.collectionGroup()` declare zero type
|
|
571
|
+
* parameters. Asking for a schema generic there produces
|
|
572
|
+
* `TS2558: Expected 0 type arguments, but got 1`, so every remediation this
|
|
573
|
+
* rule suggests is uncompilable on that surface and the receiver must be
|
|
574
|
+
* exempt.
|
|
575
|
+
*/
|
|
576
|
+
const RULES_UNIT_TESTING_MODULE = '@firebase/rules-unit-testing';
|
|
577
|
+
let rulesUnitTestingLocals;
|
|
578
|
+
/**
|
|
579
|
+
* Scanned lazily rather than from an `ImportDeclaration` visitor so that
|
|
580
|
+
* traversal order can never decide whether the exemption applies. Type-only
|
|
581
|
+
* specifiers count because a callback parameter annotated `RulesTestContext`
|
|
582
|
+
* is the other way a compat handle reaches a `.doc()` receiver.
|
|
583
|
+
*/
|
|
584
|
+
function getRulesUnitTestingLocals() {
|
|
585
|
+
if (rulesUnitTestingLocals) {
|
|
586
|
+
return rulesUnitTestingLocals;
|
|
587
|
+
}
|
|
588
|
+
const locals = new Set();
|
|
589
|
+
for (const statement of context.sourceCode.ast.body) {
|
|
590
|
+
if (statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
591
|
+
statement.source.value === RULES_UNIT_TESTING_MODULE) {
|
|
592
|
+
for (const specifier of statement.specifiers) {
|
|
593
|
+
locals.add(specifier.local.name);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
rulesUnitTestingLocals = locals;
|
|
598
|
+
return locals;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* A qualified annotation such as `rut.RulesTestContext` is rooted at the
|
|
602
|
+
* namespace binding, which is the name the import declaration provides.
|
|
603
|
+
*/
|
|
604
|
+
function rootTypeNameOf(typeNode) {
|
|
605
|
+
if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
let entity = typeNode.typeName;
|
|
609
|
+
while (entity.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
610
|
+
entity = entity.left;
|
|
611
|
+
}
|
|
612
|
+
return entity.type === utils_1.AST_NODE_TYPES.Identifier
|
|
613
|
+
? entity.name
|
|
614
|
+
: undefined;
|
|
615
|
+
}
|
|
616
|
+
function isRulesUnitTestingType(typeNode) {
|
|
617
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType ||
|
|
618
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
619
|
+
return typeNode.types.some(isRulesUnitTestingType);
|
|
620
|
+
}
|
|
621
|
+
const rootName = rootTypeNameOf(typeNode);
|
|
622
|
+
return !!rootName && getRulesUnitTestingLocals().has(rootName);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Walks an expression toward its syntactic root and reports whether that
|
|
626
|
+
* root is a value supplied by `@firebase/rules-unit-testing`.
|
|
627
|
+
*
|
|
628
|
+
* Only `const` bindings are followed, mirroring `isTypedCollectionBinding`:
|
|
629
|
+
* a `let`/`var` receiver can be reassigned to an Admin SDK handle, where the
|
|
630
|
+
* generic is both supportable and valuable, so exempting it would silently
|
|
631
|
+
* drop enforcement.
|
|
632
|
+
*/
|
|
633
|
+
function tracesToRulesUnitTesting(node, visited = new Set()) {
|
|
634
|
+
if (!node || getRulesUnitTestingLocals().size === 0) {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
// Guards against a self-referential declaration such as `const a = a.b;`.
|
|
638
|
+
if (visited.has(node)) {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
visited.add(node);
|
|
642
|
+
switch (node.type) {
|
|
643
|
+
case utils_1.AST_NODE_TYPES.AwaitExpression:
|
|
644
|
+
return tracesToRulesUnitTesting(node.argument, visited);
|
|
645
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
646
|
+
return tracesToRulesUnitTesting(node.callee, visited);
|
|
647
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
648
|
+
return tracesToRulesUnitTesting(node.object, visited);
|
|
649
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
650
|
+
return tracesToRulesUnitTesting(node.expression, visited);
|
|
651
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
652
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
653
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
654
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
655
|
+
return tracesToRulesUnitTesting(node.expression, visited);
|
|
656
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
657
|
+
return identifierTracesToRulesUnitTesting(node, visited);
|
|
658
|
+
default:
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function identifierTracesToRulesUnitTesting(node, visited) {
|
|
663
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
664
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
|
|
665
|
+
if (!variable || variable.defs.length !== 1) {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
const def = variable.defs[0];
|
|
669
|
+
if (def.type === 'ImportBinding') {
|
|
670
|
+
return (def.parent?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
671
|
+
def.parent.source.value === RULES_UNIT_TESTING_MODULE);
|
|
672
|
+
}
|
|
673
|
+
// Covers the `withSecurityRulesDisabled(async (ctx: RulesTestContext) =>
|
|
674
|
+
// ...)` callback, where the compat handle never appears as an initializer.
|
|
675
|
+
if (def.type === 'Parameter') {
|
|
676
|
+
const annotation = def.name.typeAnnotation;
|
|
677
|
+
return (!!annotation && isRulesUnitTestingType(annotation.typeAnnotation));
|
|
678
|
+
}
|
|
679
|
+
if (def.type !== 'Variable' ||
|
|
680
|
+
def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
681
|
+
def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
682
|
+
def.parent.kind !== 'const') {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
const declarator = def.node;
|
|
686
|
+
if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
687
|
+
declarator.id.typeAnnotation &&
|
|
688
|
+
isRulesUnitTestingType(declarator.id.typeAnnotation.typeAnnotation)) {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
return tracesToRulesUnitTesting(declarator.init, visited);
|
|
692
|
+
}
|
|
568
693
|
return {
|
|
569
694
|
TSTypeReference(node) {
|
|
570
695
|
if (node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -601,6 +726,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
601
726
|
if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
602
727
|
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
603
728
|
node.callee.property.name === 'doc') {
|
|
729
|
+
if (tracesToRulesUnitTesting(node.callee.object)) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
604
732
|
const typeAnnotation = node.typeParameters;
|
|
605
733
|
const isOnTypedCollection = isTypedCollectionReference(node.callee.object);
|
|
606
734
|
// If this is a .doc() call on a typed CollectionReference,
|
|
@@ -655,6 +783,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
655
783
|
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
656
784
|
node.callee.property.name === 'collection' &&
|
|
657
785
|
!isPartOfMethodChain(node)) {
|
|
786
|
+
if (tracesToRulesUnitTesting(node.callee.object)) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
658
789
|
const typeAnnotation = node.typeParameters;
|
|
659
790
|
if (!typeAnnotation) {
|
|
660
791
|
context.report({
|
|
@@ -675,6 +806,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
675
806
|
else if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
676
807
|
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
677
808
|
node.callee.property.name === 'collectionGroup') {
|
|
809
|
+
if (tracesToRulesUnitTesting(node.callee.object)) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
678
812
|
const typeAnnotation = node.typeParameters;
|
|
679
813
|
if (!typeAnnotation) {
|
|
680
814
|
context.report({
|
|
@@ -27,6 +27,7 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
|
|
|
27
27
|
// Regular expression to match standalone "id" surrounded by whitespace or punctuation
|
|
28
28
|
// This ensures we only match "id" as a word, not as part of another word
|
|
29
29
|
const idRegex = /(^|\s|[.,;:!?'"()\[\]{}])id(\s|$|[.,;:!?'"()\[\]{}])/g;
|
|
30
|
+
const sourceCode = context.getSourceCode();
|
|
30
31
|
// DOM / Testing-Library APIs whose first argument is an attribute NAME
|
|
31
32
|
// (code), not user-facing text. A literal like 'id' passed here is a DOM
|
|
32
33
|
// attribute name; flagging or rewriting it to 'ID' breaks the call.
|
|
@@ -145,6 +146,54 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
|
|
|
145
146
|
}
|
|
146
147
|
return false;
|
|
147
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Escape a string so it can sit inside a literal delimited by `delimiter`.
|
|
151
|
+
*
|
|
152
|
+
* JSON escaping already covers backslashes, control characters and every
|
|
153
|
+
* other sequence a JavaScript string literal needs, but it is hardcoded to
|
|
154
|
+
* double quotes. For a single-quoted literal the escaping is simply
|
|
155
|
+
* inverted: the double quotes go bare and the apostrophes carry the
|
|
156
|
+
* backslash.
|
|
157
|
+
*/
|
|
158
|
+
function escapeForDelimiter(text, delimiter) {
|
|
159
|
+
const jsonBody = JSON.stringify(text).slice(1, -1);
|
|
160
|
+
if (delimiter === '"') {
|
|
161
|
+
return jsonBody;
|
|
162
|
+
}
|
|
163
|
+
// Every `"` in a JSON body is escaped and every `\` belongs to an escape
|
|
164
|
+
// sequence, so unescaping the quotes first cannot corrupt a `\\` pair.
|
|
165
|
+
return jsonBody.replace(/\\"/g, '"').replace(/'/g, "\\'");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Rebuild a string literal around the corrected text, keeping the quote
|
|
169
|
+
* character the author chose. Rewriting the delimiter is a formatting
|
|
170
|
+
* regression on every fixed file, so the fix has to reuse it.
|
|
171
|
+
*/
|
|
172
|
+
function fixStringLiteral(node, fixedText) {
|
|
173
|
+
const raw = sourceCode.getText(node);
|
|
174
|
+
const delimiter = raw[0];
|
|
175
|
+
if (delimiter !== "'" && delimiter !== '"') {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const rawBody = raw.slice(1, -1);
|
|
179
|
+
const isJsxAttributeValue = node.parent && node.parent.type === utils_1.AST_NODE_TYPES.JSXAttribute;
|
|
180
|
+
// Substituting inside the raw source reproduces the file byte for byte,
|
|
181
|
+
// so take that path whenever the source between the quotes already is the
|
|
182
|
+
// parsed value. It is also the only safe path for a JSX attribute value,
|
|
183
|
+
// which the parser does not escape-process: there a backslash is a
|
|
184
|
+
// literal character (rebuilding would double it) and an entity such as
|
|
185
|
+
// " decodes to a delimiter that cannot be written back escaped.
|
|
186
|
+
if (rawBody === node.value || isJsxAttributeValue) {
|
|
187
|
+
idRegex.lastIndex = 0;
|
|
188
|
+
const fixedBody = rawBody.replace(idRegex, (_match, prefix, suffix) => `${prefix}ID${suffix}`);
|
|
189
|
+
// An entity-bearing JSX value can carry the match only in its decoded
|
|
190
|
+
// form; report it without a fix rather than emit a no-op replacement.
|
|
191
|
+
return fixedBody === rawBody
|
|
192
|
+
? null
|
|
193
|
+
: `${delimiter}${fixedBody}${delimiter}`;
|
|
194
|
+
}
|
|
195
|
+
return `${delimiter}${escapeForDelimiter(fixedText, delimiter)}${delimiter}`;
|
|
196
|
+
}
|
|
148
197
|
/**
|
|
149
198
|
* Check if a string contains "id" as a standalone word and report if found
|
|
150
199
|
*/
|
|
@@ -177,16 +226,21 @@ exports.enforceIdCapitalization = (0, createRule_1.createRule)({
|
|
|
177
226
|
node,
|
|
178
227
|
messageId: 'enforceIdCapitalization',
|
|
179
228
|
fix: (fixer) => {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
else if (node.type === utils_1.AST_NODE_TYPES.JSXText) {
|
|
229
|
+
// JSX text carries no delimiters, so its own text is the content.
|
|
230
|
+
if (node.type === utils_1.AST_NODE_TYPES.JSXText) {
|
|
184
231
|
return fixer.replaceText(node, fixedText);
|
|
185
232
|
}
|
|
186
|
-
|
|
187
|
-
|
|
233
|
+
if (node.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
234
|
+
const replacement = fixStringLiteral(node, fixedText);
|
|
235
|
+
return replacement === null
|
|
236
|
+
? null
|
|
237
|
+
: fixer.replaceText(node, replacement);
|
|
188
238
|
}
|
|
189
|
-
|
|
239
|
+
// Any other node kind (a TemplateElement, say) is a fragment of a
|
|
240
|
+
// larger construct whose delimiters and `${}` expressions live
|
|
241
|
+
// outside this node; rebuilding it from the parsed value would
|
|
242
|
+
// destroy them, so report without a fix.
|
|
243
|
+
return null;
|
|
190
244
|
},
|
|
191
245
|
});
|
|
192
246
|
}
|
|
@@ -75,6 +75,57 @@ function declaresVoidResult(returnType) {
|
|
|
75
75
|
return (typeArguments?.length === 1 &&
|
|
76
76
|
typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The type annotation a parameter declares, reached through the wrapper shapes
|
|
80
|
+
* a parameter can take. A default value (`cb = noop`) holds the annotated
|
|
81
|
+
* binding in `left`, while a rest element carries the annotation on itself and
|
|
82
|
+
* keeps only the binding name in `argument`.
|
|
83
|
+
*/
|
|
84
|
+
function parameterAnnotation(param) {
|
|
85
|
+
if (param.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
86
|
+
return param.typeAnnotation?.typeAnnotation;
|
|
87
|
+
}
|
|
88
|
+
if (param.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
|
|
89
|
+
return param.left.typeAnnotation?.typeAnnotation;
|
|
90
|
+
}
|
|
91
|
+
if (param.type === utils_1.AST_NODE_TYPES.RestElement) {
|
|
92
|
+
return (param.typeAnnotation?.typeAnnotation ??
|
|
93
|
+
(param.argument.type === utils_1.AST_NODE_TYPES.Identifier
|
|
94
|
+
? param.argument.typeAnnotation?.typeAnnotation
|
|
95
|
+
: undefined));
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether the method's sole parameter is annotated as a function.
|
|
101
|
+
*
|
|
102
|
+
* `@Memoize()` keys its cache on the argument value, compared against stored
|
|
103
|
+
* entries by deep equality. A function argument satisfies that comparison in
|
|
104
|
+
* exactly two ways and both defeat the decorator: a stable reference (a
|
|
105
|
+
* module-level function, a bound method, `this.handler`) matches the first
|
|
106
|
+
* entry, so every later call replays the first result and the body never runs
|
|
107
|
+
* again; a fresh arrow per call — the common shape — matches nothing, so every
|
|
108
|
+
* lookup misses while the map accumulates one dead closure per call and each
|
|
109
|
+
* later call pays a longer scan. Neither is an optimisation, and the fixer
|
|
110
|
+
* would apply it unattended, so such a method is skipped.
|
|
111
|
+
*
|
|
112
|
+
* The `params.length > 1` gate already encodes that the decorator keys on a
|
|
113
|
+
* single argument; this asks the remaining question, whether that argument is
|
|
114
|
+
* keyable at all.
|
|
115
|
+
*
|
|
116
|
+
* Detection is syntactic, mirroring the return-type exemption: only an
|
|
117
|
+
* annotation written as a function type declares the intent to honour. A
|
|
118
|
+
* callback reached through a type alias (`onUrl: UrlPresenter`) or hidden in a
|
|
119
|
+
* union (`cb: string | (() => void)`) is not categorically a function without a
|
|
120
|
+
* type checker this rule deliberately does not require, and an unannotated
|
|
121
|
+
* parameter declares nothing at all, so all of those keep reporting.
|
|
122
|
+
*/
|
|
123
|
+
function declaresFunctionParameter(params) {
|
|
124
|
+
if (params.length !== 1) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return parameterAnnotation(params[0])?.type === utils_1.AST_NODE_TYPES.TSFunctionType;
|
|
128
|
+
}
|
|
78
129
|
/**
|
|
79
130
|
* Matches a memoize decorator in supported syntaxes:
|
|
80
131
|
* - @Alias()
|
|
@@ -181,6 +232,11 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
181
232
|
if (node.value.params.length > 1) {
|
|
182
233
|
return;
|
|
183
234
|
}
|
|
235
|
+
// A callback argument cannot serve as a cache key, so the decorator
|
|
236
|
+
// either replays a stale result or never hits while leaking entries.
|
|
237
|
+
if (declaresFunctionParameter(node.value.params)) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
184
240
|
// A method declared to produce no value has no result to cache, so the
|
|
185
241
|
// decorator's benefit is unobtainable while its cost is real: the
|
|
186
242
|
// fixer would convert a repeatable side effect into a
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.enforceMemoizeGetters = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const visitor_keys_1 = require("@typescript-eslint/visitor-keys");
|
|
5
6
|
const createRule_1 = require("../utils/createRule");
|
|
6
7
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
8
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
@@ -68,6 +69,238 @@ function isMemoizeDecorator(decorator, alias) {
|
|
|
68
69
|
}
|
|
69
70
|
return false;
|
|
70
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Node modules whose exports observe or drive the world outside the process.
|
|
74
|
+
* A getter that reaches one of them yields a fresh observation per access, and
|
|
75
|
+
* `@Memoize()` would pin the first observation for the life of the instance —
|
|
76
|
+
* a behaviour change, not an optimization.
|
|
77
|
+
*/
|
|
78
|
+
const IO_MODULES = new Set([
|
|
79
|
+
'child_process',
|
|
80
|
+
'node:child_process',
|
|
81
|
+
'fs',
|
|
82
|
+
'node:fs',
|
|
83
|
+
'fs/promises',
|
|
84
|
+
'node:fs/promises',
|
|
85
|
+
'net',
|
|
86
|
+
'node:net',
|
|
87
|
+
'http',
|
|
88
|
+
'node:http',
|
|
89
|
+
'https',
|
|
90
|
+
'node:https',
|
|
91
|
+
'dns',
|
|
92
|
+
'node:dns',
|
|
93
|
+
]);
|
|
94
|
+
/**
|
|
95
|
+
* Builtins whose result differs between two otherwise identical accesses, so
|
|
96
|
+
* the cached first value stops tracking whatever the getter samples.
|
|
97
|
+
*/
|
|
98
|
+
const NON_DETERMINISTIC_CALLS = new Set([
|
|
99
|
+
'Date.now',
|
|
100
|
+
'Math.random',
|
|
101
|
+
'performance.now',
|
|
102
|
+
'crypto.randomUUID',
|
|
103
|
+
'crypto.getRandomValues',
|
|
104
|
+
'process.hrtime',
|
|
105
|
+
'process.hrtime.bigint',
|
|
106
|
+
]);
|
|
107
|
+
/**
|
|
108
|
+
* The dotted path of a chain of plain identifiers (`process.hrtime.bigint`), or
|
|
109
|
+
* `null` once any link is computed or is not an identifier. Keying on the
|
|
110
|
+
* spelled path keeps the analysis syntactic, which is all this rule has: it
|
|
111
|
+
* runs without `parserOptions.project`, so no type information is available.
|
|
112
|
+
*/
|
|
113
|
+
function staticMemberPath(node) {
|
|
114
|
+
if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
115
|
+
return node.name;
|
|
116
|
+
}
|
|
117
|
+
if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
118
|
+
!node.computed &&
|
|
119
|
+
node.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
120
|
+
const objectPath = staticMemberPath(node.object);
|
|
121
|
+
return objectPath === null ? null : `${objectPath}.${node.property.name}`;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The member name a key spells, covering the forms `this.<name>` can reach:
|
|
127
|
+
* plain identifiers, string-literal keys, and private names.
|
|
128
|
+
*/
|
|
129
|
+
function keyName(key) {
|
|
130
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
131
|
+
return key.name;
|
|
132
|
+
}
|
|
133
|
+
if (key.type === utils_1.AST_NODE_TYPES.PrivateIdentifier) {
|
|
134
|
+
return `#${key.name}`;
|
|
135
|
+
}
|
|
136
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
137
|
+
return key.value;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/** The member name of a `this.<name>` access, or `null` for anything else. */
|
|
142
|
+
function thisMemberName(node) {
|
|
143
|
+
if (node.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
144
|
+
node.object.type !== utils_1.AST_NODE_TYPES.ThisExpression) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return keyName(node.property);
|
|
148
|
+
}
|
|
149
|
+
function forEachDescendant(root, visit) {
|
|
150
|
+
const stack = [root];
|
|
151
|
+
while (stack.length > 0) {
|
|
152
|
+
const current = stack.pop();
|
|
153
|
+
if (!current) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
visit(current);
|
|
157
|
+
const keys = visitor_keys_1.visitorKeys[current.type] ?? [];
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
const value = current[key];
|
|
160
|
+
if (Array.isArray(value)) {
|
|
161
|
+
for (const element of value) {
|
|
162
|
+
if (ASTHelpers_1.ASTHelpers.isNode(element)) {
|
|
163
|
+
stack.push(element);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
168
|
+
stack.push(value);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Local names bound by an import of a Node I/O module, covering named, default
|
|
175
|
+
* and namespace specifiers alike: a call through any of them — `execFileSync()`
|
|
176
|
+
* or `fs.readFileSync()` — reaches the same I/O.
|
|
177
|
+
*/
|
|
178
|
+
function collectIoBindings(program) {
|
|
179
|
+
const bindings = new Set();
|
|
180
|
+
for (const statement of program.body) {
|
|
181
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
182
|
+
statement.importKind === 'type' ||
|
|
183
|
+
!IO_MODULES.has(String(statement.source.value))) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
for (const specifier of statement.specifiers) {
|
|
187
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
188
|
+
specifier.importKind === 'type') {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
bindings.add(specifier.local.name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return bindings;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Whether a single node is one of the seed impurities: a call reaching a Node
|
|
198
|
+
* I/O binding, a non-deterministic builtin, or a read of `process.env`.
|
|
199
|
+
*/
|
|
200
|
+
function isImpureSeed(node, ioBindings) {
|
|
201
|
+
// `new Date()` samples the wall clock; `new Date(value)` is a pure conversion.
|
|
202
|
+
if (node.type === utils_1.AST_NODE_TYPES.NewExpression) {
|
|
203
|
+
return (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
204
|
+
node.callee.name === 'Date' &&
|
|
205
|
+
node.arguments.length === 0);
|
|
206
|
+
}
|
|
207
|
+
// Every `process.env.X` / `process.env['X']` read contains this subexpression.
|
|
208
|
+
if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
209
|
+
return staticMemberPath(node) === 'process.env';
|
|
210
|
+
}
|
|
211
|
+
if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const calleePath = staticMemberPath(node.callee);
|
|
215
|
+
if (calleePath === null) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return (NON_DETERMINISTIC_CALLS.has(calleePath) ||
|
|
219
|
+
ioBindings.has(calleePath.split('.')[0]));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The function a class element carries and the name `this.<name>` reaches it
|
|
223
|
+
* by, for the element kinds a getter can delegate to: methods, accessors, and
|
|
224
|
+
* fields holding a function. Anything else (a static block, a plain field) is
|
|
225
|
+
* not a delegation target.
|
|
226
|
+
*/
|
|
227
|
+
function describeMember(element) {
|
|
228
|
+
if (element.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
|
|
229
|
+
element.type === utils_1.AST_NODE_TYPES.TSAbstractMethodDefinition) {
|
|
230
|
+
return { fn: element.value, name: keyName(element.key) };
|
|
231
|
+
}
|
|
232
|
+
if (element.type === utils_1.AST_NODE_TYPES.PropertyDefinition &&
|
|
233
|
+
(element.value?.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
234
|
+
element.value?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
|
|
235
|
+
return { fn: element.value, name: keyName(element.key) };
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The class elements whose value depends on live external state, as a
|
|
241
|
+
* class-local fixpoint: seed the members that reach I/O or a non-deterministic
|
|
242
|
+
* builtin directly, then keep marking any member that touches an already-impure
|
|
243
|
+
* `this.<member>` until nothing changes.
|
|
244
|
+
*
|
|
245
|
+
* The iteration — rather than a single direct-callee check — is what covers the
|
|
246
|
+
* production shape that motivates the exemption: a getter delegating to a
|
|
247
|
+
* sibling private method that itself delegates further before reaching the I/O
|
|
248
|
+
* call.
|
|
249
|
+
*/
|
|
250
|
+
function analyzeClassImpurity(classBody, ioBindings) {
|
|
251
|
+
const analyses = [];
|
|
252
|
+
const byName = new Map();
|
|
253
|
+
for (const element of classBody.body) {
|
|
254
|
+
const member = describeMember(element);
|
|
255
|
+
if (!member) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const analysis = {
|
|
259
|
+
element,
|
|
260
|
+
dependencies: new Set(),
|
|
261
|
+
impure: false,
|
|
262
|
+
};
|
|
263
|
+
forEachDescendant(member.fn, (node) => {
|
|
264
|
+
if (isImpureSeed(node, ioBindings)) {
|
|
265
|
+
analysis.impure = true;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// A bare read of `this.<member>` counts alongside a call: reading an
|
|
269
|
+
// impure getter is exactly the propagation path this analysis exists for.
|
|
270
|
+
const dependency = thisMemberName(node);
|
|
271
|
+
if (dependency !== null) {
|
|
272
|
+
analysis.dependencies.add(dependency);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
analyses.push(analysis);
|
|
276
|
+
if (member.name !== null) {
|
|
277
|
+
const siblings = byName.get(member.name);
|
|
278
|
+
if (siblings) {
|
|
279
|
+
siblings.push(analysis);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
byName.set(member.name, [analysis]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
let changed = true;
|
|
287
|
+
while (changed) {
|
|
288
|
+
changed = false;
|
|
289
|
+
for (const analysis of analyses) {
|
|
290
|
+
if (analysis.impure) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const reachesImpure = [...analysis.dependencies].some((dependency) => (byName.get(dependency) ?? []).some((target) => target.impure));
|
|
294
|
+
if (reachesImpure) {
|
|
295
|
+
analysis.impure = true;
|
|
296
|
+
changed = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return new Set(analyses
|
|
301
|
+
.filter((analysis) => analysis.impure)
|
|
302
|
+
.map(({ element }) => element));
|
|
303
|
+
}
|
|
71
304
|
exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
72
305
|
name: 'enforce-memoize-getters',
|
|
73
306
|
meta: {
|
|
@@ -150,6 +383,24 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
|
150
383
|
}
|
|
151
384
|
return memoizeImportCache;
|
|
152
385
|
};
|
|
386
|
+
let ioBindingCache = null;
|
|
387
|
+
const ioBindings = () => {
|
|
388
|
+
if (!ioBindingCache) {
|
|
389
|
+
ioBindingCache = collectIoBindings(sourceCode.ast);
|
|
390
|
+
}
|
|
391
|
+
return ioBindingCache;
|
|
392
|
+
};
|
|
393
|
+
// One fixpoint per class serves every getter it declares.
|
|
394
|
+
const impurityCache = new Map();
|
|
395
|
+
const impureMembersOf = (classBody) => {
|
|
396
|
+
const cached = impurityCache.get(classBody);
|
|
397
|
+
if (cached) {
|
|
398
|
+
return cached;
|
|
399
|
+
}
|
|
400
|
+
const analyzed = analyzeClassImpurity(classBody, ioBindings());
|
|
401
|
+
impurityCache.set(classBody, analyzed);
|
|
402
|
+
return analyzed;
|
|
403
|
+
};
|
|
153
404
|
return {
|
|
154
405
|
MethodDefinition(node) {
|
|
155
406
|
// Target: instance private getters
|
|
@@ -161,6 +412,14 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
|
161
412
|
// enforce only "private" accessibility (undefined => public)
|
|
162
413
|
if (node.accessibility !== 'private')
|
|
163
414
|
return;
|
|
415
|
+
// A getter whose value is a fresh read of live external state is not a
|
|
416
|
+
// lazy factory: memoizing it pins the first observation forever, so the
|
|
417
|
+
// report is dropped along with its unattended `--fix` edit.
|
|
418
|
+
const classBody = node.parent;
|
|
419
|
+
if (classBody?.type === utils_1.AST_NODE_TYPES.ClassBody &&
|
|
420
|
+
impureMembersOf(classBody).has(node)) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
164
423
|
const { hasMemoizeImport, memoizeAlias, memoizeNamespace, hasNamedImport, } = memoizeImports();
|
|
165
424
|
const decoratorAliases = memoizeAlias === MEMOIZE_NAME
|
|
166
425
|
? [MEMOIZE_NAME]
|
|
@@ -390,6 +390,79 @@ function getModuleScopeValueBindings(program) {
|
|
|
390
390
|
}
|
|
391
391
|
return names;
|
|
392
392
|
}
|
|
393
|
+
/** The leading whitespace of the line the offset sits on. */
|
|
394
|
+
function indentationAt(sourceCode, offset) {
|
|
395
|
+
const text = sourceCode.getText();
|
|
396
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
397
|
+
const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
|
|
398
|
+
return match ? match[0] : '';
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Ranges whose interior line breaks carry string data rather than formatting.
|
|
402
|
+
* A multi-line template literal (or a string spliced together with line
|
|
403
|
+
* continuations) evaluates to the whitespace written inside it, so shifting
|
|
404
|
+
* those lines would silently change the value the code produces.
|
|
405
|
+
*/
|
|
406
|
+
function stringDataRangesOf(sourceCode, node) {
|
|
407
|
+
return sourceCode
|
|
408
|
+
.getTokens(node)
|
|
409
|
+
.filter((token) => (token.type === utils_1.AST_TOKEN_TYPES.Template ||
|
|
410
|
+
token.type === utils_1.AST_TOKEN_TYPES.String) &&
|
|
411
|
+
token.loc.start.line !== token.loc.end.line)
|
|
412
|
+
.map((token) => token.range);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The callback's text re-indented for the module scope it is hoisted into.
|
|
416
|
+
*
|
|
417
|
+
* Module scope sits at least one nesting level shallower than the component
|
|
418
|
+
* body the callback is lifted out of, so splicing the text verbatim leaves
|
|
419
|
+
* every interior line — and the closing brace — indented for a level the
|
|
420
|
+
* declaration no longer occupies (issue #1560). Each line therefore moves by
|
|
421
|
+
* the difference between the callback's original indentation and the
|
|
422
|
+
* indentation of the statement the declaration is inserted before. The
|
|
423
|
+
* callback's own line is the reference rather than the declaration's, so a
|
|
424
|
+
* callback broken onto its own argument line sheds that extra level too.
|
|
425
|
+
*/
|
|
426
|
+
function dedentedCallbackText(sourceCode, callback, hoistTarget) {
|
|
427
|
+
const text = sourceCode.getText(callback);
|
|
428
|
+
const targetIndent = indentationAt(sourceCode, hoistTarget.range[0]);
|
|
429
|
+
const callbackIndent = indentationAt(sourceCode, callback.range[0]);
|
|
430
|
+
if (targetIndent === callbackIndent) {
|
|
431
|
+
return text;
|
|
432
|
+
}
|
|
433
|
+
const shiftLine = (() => {
|
|
434
|
+
if (callbackIndent.startsWith(targetIndent)) {
|
|
435
|
+
const removed = callbackIndent.slice(targetIndent.length);
|
|
436
|
+
return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
|
|
437
|
+
}
|
|
438
|
+
if (targetIndent.startsWith(callbackIndent)) {
|
|
439
|
+
const added = targetIndent.slice(callbackIndent.length);
|
|
440
|
+
return (line) => `${added}${line}`;
|
|
441
|
+
}
|
|
442
|
+
// Indent characters that disagree give no delta that can be applied
|
|
443
|
+
// without corrupting the layout, so the text is left as the author wrote it.
|
|
444
|
+
return null;
|
|
445
|
+
})();
|
|
446
|
+
if (!shiftLine) {
|
|
447
|
+
return text;
|
|
448
|
+
}
|
|
449
|
+
const stringData = stringDataRangesOf(sourceCode, callback);
|
|
450
|
+
const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
|
|
451
|
+
let offset = callback.range[0];
|
|
452
|
+
return text
|
|
453
|
+
.split('\n')
|
|
454
|
+
.map((line, index) => {
|
|
455
|
+
const lineStart = offset;
|
|
456
|
+
offset += line.length + 1;
|
|
457
|
+
// The first line is spliced in after the hoisted declaration's `=`, so it
|
|
458
|
+
// has no indentation of its own left to adjust.
|
|
459
|
+
if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
|
|
460
|
+
return line;
|
|
461
|
+
}
|
|
462
|
+
return shiftLine(line);
|
|
463
|
+
})
|
|
464
|
+
.join('\n');
|
|
465
|
+
}
|
|
393
466
|
function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCache) {
|
|
394
467
|
if (!callExpression.parent ||
|
|
395
468
|
callExpression.parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
@@ -442,7 +515,7 @@ function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCac
|
|
|
442
515
|
const identifierText = sourceCode
|
|
443
516
|
.getText()
|
|
444
517
|
.slice(declarator.id.range[0], idRangeEnd);
|
|
445
|
-
const functionText = sourceCode
|
|
518
|
+
const functionText = dedentedCallbackText(sourceCode, callback, hoistTarget);
|
|
446
519
|
const hoisted = `const ${identifierText} = ${functionText};\n`;
|
|
447
520
|
const fileText = sourceCode.getText();
|
|
448
521
|
let removeStart = varDecl.range[0];
|
|
@@ -10,6 +10,7 @@ const defaultOptions = {
|
|
|
10
10
|
allowAbstractMethodSignatures: true,
|
|
11
11
|
allowDtsFiles: true,
|
|
12
12
|
allowFirestoreFunctionFiles: true,
|
|
13
|
+
allowVoidReturnTypes: true,
|
|
13
14
|
};
|
|
14
15
|
function getNameFromIdentifierOrLiteral(key) {
|
|
15
16
|
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -576,6 +577,45 @@ function isReadonlyWideningReturnType(returnType) {
|
|
|
576
577
|
}
|
|
577
578
|
return false;
|
|
578
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Returns true when the annotation declares that the function produces no
|
|
582
|
+
* value: a bare `void`, or `Promise<void>` with exactly one type argument.
|
|
583
|
+
*
|
|
584
|
+
* The rule's case against an explicit return type is that it restates what the
|
|
585
|
+
* implementation already returns and "can drift from what the implementation
|
|
586
|
+
* actually returns, hiding bugs behind a stale type". That case does not reach
|
|
587
|
+
* `void`/`Promise<void>`. Such an annotation is not a restatement of a result,
|
|
588
|
+
* it is a declaration that there is no result, and TypeScript enforces it:
|
|
589
|
+
* adding `return <expr>` to a function annotated `Promise<void>` is a compile
|
|
590
|
+
* error. It cannot drift into a lie, so removing it destroys information rather
|
|
591
|
+
* than removing redundancy.
|
|
592
|
+
*
|
|
593
|
+
* That information is load-bearing for other rules: `enforce-memoize-async`
|
|
594
|
+
* reads exactly this declaration of intent and skips a method declared
|
|
595
|
+
* `Promise<void>`, because caching a call that yields nothing turns a repeatable
|
|
596
|
+
* side effect into a once-per-instance one. Stripping the annotation happens
|
|
597
|
+
* unattended — `eslint --fix` re-lints until the output settles, so the strip
|
|
598
|
+
* and the memoization that follows it land in the same run.
|
|
599
|
+
*
|
|
600
|
+
* The shape match stays deliberately exact, mirroring the check
|
|
601
|
+
* `enforce-memoize-async` performs. A union (`Promise<void | string>`), a nested
|
|
602
|
+
* wrapper (`Promise<Awaited<void>>`) or any other arity can resolve to a value,
|
|
603
|
+
* so those annotations remain redundant restatements and stay reportable.
|
|
604
|
+
*/
|
|
605
|
+
function declaresVoidResult(returnType) {
|
|
606
|
+
const annotation = returnType.typeAnnotation;
|
|
607
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSVoidKeyword) {
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
if (annotation.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
611
|
+
annotation.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
612
|
+
annotation.typeName.name !== 'Promise') {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
const typeArguments = annotation.typeParameters?.params;
|
|
616
|
+
return (typeArguments?.length === 1 &&
|
|
617
|
+
typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
|
|
618
|
+
}
|
|
579
619
|
exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
580
620
|
name: 'no-explicit-return-type',
|
|
581
621
|
meta: {
|
|
@@ -597,6 +637,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
597
637
|
allowAbstractMethodSignatures: { type: 'boolean' },
|
|
598
638
|
allowDtsFiles: { type: 'boolean' },
|
|
599
639
|
allowFirestoreFunctionFiles: { type: 'boolean' },
|
|
640
|
+
allowVoidReturnTypes: { type: 'boolean' },
|
|
600
641
|
},
|
|
601
642
|
additionalProperties: false,
|
|
602
643
|
},
|
|
@@ -649,6 +690,18 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
649
690
|
filename.endsWith('.f.ts'))) {
|
|
650
691
|
return {};
|
|
651
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Applied at the implementation sites this rule's fixer can rewrite —
|
|
695
|
+
* functions, arrows and class methods. Signature-only declarations
|
|
696
|
+
* (interface methods, abstract methods, `declare function`) are outside
|
|
697
|
+
* the exemption: they have no body to infer from, so their annotation is
|
|
698
|
+
* mandatory rather than redundant, they are reported only when the
|
|
699
|
+
* matching `allow*` option is turned off, and no fixer ever strips them.
|
|
700
|
+
*/
|
|
701
|
+
function isAllowedVoidReturnType(returnType) {
|
|
702
|
+
return (Boolean(mergedOptions.allowVoidReturnTypes) &&
|
|
703
|
+
declaresVoidResult(returnType));
|
|
704
|
+
}
|
|
652
705
|
function fixReturnType(fixer, node) {
|
|
653
706
|
// Some nodes expose returnType directly while others nest it under value.
|
|
654
707
|
const returnType = 'returnType' in node
|
|
@@ -667,6 +720,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
667
720
|
return;
|
|
668
721
|
if (isTypeGuardFunction(node) ||
|
|
669
722
|
isReadonlyWideningReturnType(returnType) ||
|
|
723
|
+
isAllowedVoidReturnType(returnType) ||
|
|
670
724
|
(mergedOptions.allowRecursiveFunctions &&
|
|
671
725
|
isRecursiveFunction(node)) ||
|
|
672
726
|
isReturnTypeRequiredByRecursion(node)) {
|
|
@@ -693,6 +747,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
693
747
|
}
|
|
694
748
|
if (isTypeGuardFunction(node) ||
|
|
695
749
|
isReadonlyWideningReturnType(returnType) ||
|
|
750
|
+
isAllowedVoidReturnType(returnType) ||
|
|
696
751
|
(mergedOptions.allowRecursiveFunctions &&
|
|
697
752
|
isRecursiveFunction(node)) ||
|
|
698
753
|
isReturnTypeRequiredByRecursion(node)) {
|
|
@@ -711,6 +766,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
711
766
|
return;
|
|
712
767
|
if (isTypeGuardFunction(node) ||
|
|
713
768
|
isReadonlyWideningReturnType(returnType) ||
|
|
769
|
+
isAllowedVoidReturnType(returnType) ||
|
|
714
770
|
isReturnTypeRequiredByRecursion(node)) {
|
|
715
771
|
return;
|
|
716
772
|
}
|
|
@@ -744,6 +800,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
744
800
|
return;
|
|
745
801
|
if (isTypeGuardFunction(node.value) ||
|
|
746
802
|
isReadonlyWideningReturnType(returnType) ||
|
|
803
|
+
isAllowedVoidReturnType(returnType) ||
|
|
747
804
|
(mergedOptions.allowAbstractMethodSignatures &&
|
|
748
805
|
isInterfaceOrAbstractMethodSignature(node)) ||
|
|
749
806
|
isReturnTypeRequiredByRecursion(node)) {
|
|
@@ -44,6 +44,81 @@ const findLatestCallbackImport = (program) => {
|
|
|
44
44
|
}
|
|
45
45
|
return null;
|
|
46
46
|
};
|
|
47
|
+
/** The leading whitespace of the line the offset sits on. */
|
|
48
|
+
const indentationAt = (sourceCode, offset) => {
|
|
49
|
+
const text = sourceCode.getText();
|
|
50
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
51
|
+
const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
|
|
52
|
+
return match ? match[0] : '';
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Ranges whose interior line breaks carry string data rather than formatting.
|
|
56
|
+
* A multi-line template literal (or a string spliced together with line
|
|
57
|
+
* continuations) evaluates to the whitespace written inside it, so shifting
|
|
58
|
+
* those lines would silently change the value the code produces — the same
|
|
59
|
+
* carve-out `parallelize-async-operations` makes for spliced-in arguments.
|
|
60
|
+
*/
|
|
61
|
+
const stringDataRangesOf = (sourceCode, node) => sourceCode
|
|
62
|
+
.getTokens(node)
|
|
63
|
+
.filter((token) => (token.type === utils_1.AST_TOKEN_TYPES.Template ||
|
|
64
|
+
token.type === utils_1.AST_TOKEN_TYPES.String) &&
|
|
65
|
+
token.loc.start.line !== token.loc.end.line)
|
|
66
|
+
.map((token) => token.range);
|
|
67
|
+
/**
|
|
68
|
+
* The callback's text re-indented for the line the rewritten call puts it on.
|
|
69
|
+
*
|
|
70
|
+
* Dropping the dependency array lets the call collapse onto one line, and when
|
|
71
|
+
* the original spelled the callback on a line of its own that collapse removes
|
|
72
|
+
* exactly one nesting level. Emitting the callback verbatim would leave every
|
|
73
|
+
* interior line indented for the level it no longer occupies (issue #1559), so
|
|
74
|
+
* each line moves by the difference between the callback's original indentation
|
|
75
|
+
* and the indentation of the line the call starts on. Preserving the original
|
|
76
|
+
* multi-line layout instead is not an option: a lone function argument is
|
|
77
|
+
* hugged onto the call line by the formatter, so the broken-out form would be
|
|
78
|
+
* reformatted away on the next write.
|
|
79
|
+
*/
|
|
80
|
+
const reindentedCallbackText = (sourceCode, call, callback) => {
|
|
81
|
+
const text = sourceCode.getText(callback);
|
|
82
|
+
const callIndent = indentationAt(sourceCode, call.range[0]);
|
|
83
|
+
const callbackIndent = indentationAt(sourceCode, callback.range[0]);
|
|
84
|
+
// A callback already on the call's line loses no nesting level, so its body
|
|
85
|
+
// must be reproduced byte for byte.
|
|
86
|
+
if (callIndent === callbackIndent) {
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
const shiftLine = (() => {
|
|
90
|
+
if (callbackIndent.startsWith(callIndent)) {
|
|
91
|
+
const removed = callbackIndent.slice(callIndent.length);
|
|
92
|
+
return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
|
|
93
|
+
}
|
|
94
|
+
if (callIndent.startsWith(callbackIndent)) {
|
|
95
|
+
const added = callIndent.slice(callbackIndent.length);
|
|
96
|
+
return (line) => `${added}${line}`;
|
|
97
|
+
}
|
|
98
|
+
// Indent characters that disagree give no delta that can be applied
|
|
99
|
+
// without corrupting the layout, so the text is left as the author wrote it.
|
|
100
|
+
return null;
|
|
101
|
+
})();
|
|
102
|
+
if (!shiftLine) {
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
const stringData = stringDataRangesOf(sourceCode, callback);
|
|
106
|
+
const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
|
|
107
|
+
let offset = callback.range[0];
|
|
108
|
+
return text
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((line, index) => {
|
|
111
|
+
const lineStart = offset;
|
|
112
|
+
offset += line.length + 1;
|
|
113
|
+
// The first line is spliced in after the call's open paren, so it has no
|
|
114
|
+
// indentation of its own left to adjust.
|
|
115
|
+
if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
|
|
116
|
+
return line;
|
|
117
|
+
}
|
|
118
|
+
return shiftLine(line);
|
|
119
|
+
})
|
|
120
|
+
.join('\n');
|
|
121
|
+
};
|
|
47
122
|
exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
48
123
|
name: 'use-latest-callback',
|
|
49
124
|
meta: {
|
|
@@ -406,7 +481,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
406
481
|
return fixes;
|
|
407
482
|
};
|
|
408
483
|
const conversionFix = (fixer, conversion) => {
|
|
409
|
-
const callbackText = sourceCode.
|
|
484
|
+
const callbackText = reindentedCallbackText(sourceCode, conversion.node, conversion.node.arguments[0]);
|
|
410
485
|
const typeParams = conversion.node.typeParameters
|
|
411
486
|
? sourceCode.getText(conversion.node.typeParameters)
|
|
412
487
|
: '';
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,72 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.65",
|
|
4
|
+
"date": "2026-08-01T13:35:40.762Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-firestore-doc-ref-generic",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1564
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt compat Firestore receivers from @firebase/rules-unit-testing (closes #1564)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.64",
|
|
18
|
+
"date": "2026-08-01T12:02:23.073Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-id-capitalization",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1558
|
|
25
|
+
],
|
|
26
|
+
"summary": "preserve the original quote delimiter when rewriting a literal (closes #1558)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-memoize-async",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1563
|
|
33
|
+
],
|
|
34
|
+
"summary": "skip methods whose sole parameter is a callback (closes #1563)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "enforce-memoize-getters",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1561
|
|
41
|
+
],
|
|
42
|
+
"summary": "exempt getters that read live external state (closes #1561)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "no-empty-dependency-use-callbacks",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1560
|
|
49
|
+
],
|
|
50
|
+
"summary": "dedent the callback when hoisting it to module scope (closes #1560)"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "no-explicit-return-type",
|
|
54
|
+
"changeType": "fix",
|
|
55
|
+
"issues": [
|
|
56
|
+
1562
|
|
57
|
+
],
|
|
58
|
+
"summary": "keep void and Promise<void> annotations under allowVoidReturnTypes (closes #1562)"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "use-latest-callback",
|
|
62
|
+
"changeType": "fix",
|
|
63
|
+
"issues": [
|
|
64
|
+
1559
|
|
65
|
+
],
|
|
66
|
+
"summary": "re-indent the callback body when collapsing a multi-line useCallback (closes #1559)"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
},
|
|
2
70
|
{
|
|
3
71
|
"version": "1.20.63",
|
|
4
72
|
"date": "2026-08-01T09:37:04.617Z",
|