@blumintinc/eslint-plugin-blumint 1.20.136 → 1.20.138
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 +46 -3
- package/lib/rules/enforce-firestore-set-merge.js +278 -66
- package/lib/rules/enforce-microdiff.js +87 -20
- package/lib/rules/fast-deep-equal-over-microdiff.js +220 -71
- package/lib/rules/key-only-outermost-element.js +159 -21
- package/lib/rules/logical-top-to-bottom-grouping.js +104 -33
- package/lib/rules/no-empty-dependency-use-callbacks.js +116 -43
- package/lib/rules/no-explicit-return-type.js +164 -21
- package/lib/rules/no-mock-firebase-admin.js +52 -8
- package/lib/rules/no-redundant-usecallback-wrapper.d.ts +2 -1
- package/lib/rules/no-redundant-usecallback-wrapper.js +126 -70
- package/lib/rules/no-unused-props.js +69 -57
- package/lib/rules/no-useless-usememo-primitives.d.ts +2 -1
- package/lib/rules/no-useless-usememo-primitives.js +156 -47
- package/lib/rules/no-usememo-for-pass-by-value.js +165 -220
- package/lib/rules/prefer-params-over-parent-id.d.ts +2 -1
- package/lib/rules/prefer-params-over-parent-id.js +122 -39
- package/lib/rules/use-latest-callback.js +109 -11
- package/lib/rules/vertically-group-related-functions.js +41 -10
- package/lib/utils/importRemoval.d.ts +88 -0
- package/lib/utils/importRemoval.js +127 -46
- package/lib/utils/patternBindingRemoval.d.ts +28 -0
- package/lib/utils/patternBindingRemoval.js +199 -0
- package/lib/utils/typeDeclarationRemoval.d.ts +32 -0
- package/lib/utils/typeDeclarationRemoval.js +105 -0
- package/package.json +1 -1
- package/release-manifest.json +140 -0
|
@@ -5,6 +5,8 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
7
7
|
const importRemoval_1 = require("../utils/importRemoval");
|
|
8
|
+
const typeDeclarationRemoval_1 = require("../utils/typeDeclarationRemoval");
|
|
9
|
+
const replacementSegments_1 = require("../utils/replacementSegments");
|
|
8
10
|
const lexicalScope_1 = require("../utils/lexicalScope");
|
|
9
11
|
const defaultOptions = {
|
|
10
12
|
allowRecursiveFunctions: true,
|
|
@@ -716,12 +718,17 @@ function containsRange(outer, inner) {
|
|
|
716
718
|
/**
|
|
717
719
|
* Partitions annotations into the sets whose removals have to travel together.
|
|
718
720
|
*
|
|
719
|
-
*
|
|
721
|
+
* A binding read from two annotations is unbound only once both are gone, so
|
|
720
722
|
* neither annotation may unbind it alone — and a fix may only count on the other
|
|
721
723
|
* removal happening if it performs that removal itself. Annotations that jointly
|
|
722
|
-
* keep
|
|
724
|
+
* keep a binding alive are therefore merged into one batch, and every other
|
|
723
725
|
* annotation is a batch of one, judged against the file as it stands.
|
|
724
726
|
*
|
|
727
|
+
* EVERY binding is asked about, not only the imported ones. Partitioning over
|
|
728
|
+
* imports alone left a local `type` alias named by two annotations owned by no
|
|
729
|
+
* fix: each annotation looked innocent alone, both were stripped, and the alias
|
|
730
|
+
* survived with nothing referencing it (#1902).
|
|
731
|
+
*
|
|
725
732
|
* `candidates` must already exclude suppressed reports: a suppressed report's
|
|
726
733
|
* fix never runs, so its annotation — and the reference it holds — outlives the
|
|
727
734
|
* pass.
|
|
@@ -736,13 +743,13 @@ function batchAnnotations(source, candidates) {
|
|
|
736
743
|
return root;
|
|
737
744
|
};
|
|
738
745
|
const ownerOf = (reference) => candidates.findIndex((candidate) => containsRange(candidate.returnType.range, reference));
|
|
739
|
-
for (const binding of (0, importRemoval_1.
|
|
746
|
+
for (const binding of (0, importRemoval_1.bindingUses)(source)) {
|
|
740
747
|
// A single reference cannot be shared, so the one-annotation judgement
|
|
741
748
|
// already covers it.
|
|
742
|
-
if (binding.
|
|
749
|
+
if (binding.uses.length < 2)
|
|
743
750
|
continue;
|
|
744
751
|
const owners = new Set();
|
|
745
|
-
const escapes = binding.
|
|
752
|
+
const escapes = binding.uses.some((reference) => {
|
|
746
753
|
const owner = ownerOf(reference);
|
|
747
754
|
if (owner === -1)
|
|
748
755
|
return true;
|
|
@@ -774,15 +781,110 @@ function batchAnnotations(source, candidates) {
|
|
|
774
781
|
return [...batches.values()];
|
|
775
782
|
}
|
|
776
783
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
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
|
+
/**
|
|
803
|
+
* Whether the span deletes a whole declaration of the program.
|
|
804
|
+
*
|
|
805
|
+
* Such a span owns the comments inside it: they describe the declaration that is
|
|
806
|
+
* going away, and re-emitting them would leave prose behind with no subject. The
|
|
807
|
+
* spans that reach across a specifier list are the opposite case — the
|
|
808
|
+
* declaration survives them, so a comment they cover has to survive too.
|
|
809
|
+
*/
|
|
810
|
+
function removesWholeStatement(source, range) {
|
|
811
|
+
return source.ast.body.some((statement) => statement.range[0] >= range[0] && statement.range[1] <= range[1]);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* What a removal range is replaced with: nothing, or the comments it covers
|
|
815
|
+
* re-emitted so the deletion carries them instead of dropping them. `null`
|
|
816
|
+
* withholds the fix, for a comment whose meaning is its position.
|
|
817
|
+
*
|
|
818
|
+
* The separators around the carried run are chosen from the text on either side
|
|
819
|
+
* rather than added unconditionally, so a range that already sits between
|
|
820
|
+
* whitespace does not gain any. A trailing line break is mandatory where the
|
|
821
|
+
* last comment is a line comment or the range consumed its own newline: without
|
|
822
|
+
* one the surviving code moves onto the comment's line and is commented out.
|
|
823
|
+
*/
|
|
824
|
+
function carriedText(source, range) {
|
|
825
|
+
const comments = source
|
|
826
|
+
.getAllComments()
|
|
827
|
+
.filter((comment) => comment.range[0] >= range[0] && comment.range[1] <= range[1]);
|
|
828
|
+
if (comments.length === 0)
|
|
829
|
+
return '';
|
|
830
|
+
if (comments.some(isPositionalDirective))
|
|
831
|
+
return null;
|
|
832
|
+
const indent = indentAt(source, range[0]);
|
|
833
|
+
const segments = comments.map((comment) => ({
|
|
834
|
+
text: source.text.slice(comment.range[0], comment.range[1]),
|
|
835
|
+
breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
|
|
836
|
+
}));
|
|
837
|
+
const body = (0, replacementSegments_1.joinSegmentBody)(segments, indent);
|
|
838
|
+
const before = range[0] > 0 ? source.text[range[0] - 1] : '';
|
|
839
|
+
const after = source.text[range[1]] ?? '';
|
|
840
|
+
const lead = before === '' || /\s/.test(before) ? '' : ' ';
|
|
841
|
+
const trail = segments[segments.length - 1].breakAfter ||
|
|
842
|
+
source.text.slice(range[0], range[1]).endsWith('\n')
|
|
843
|
+
? `\n${indent}`
|
|
844
|
+
: after === '' || /\s/.test(after)
|
|
845
|
+
? ''
|
|
846
|
+
: ' ';
|
|
847
|
+
return `${lead}${body}${trail}`;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* ESLint applies a fix whole or not at all, and rejects one whose edits
|
|
851
|
+
* overlap. Two spans planned independently — an annotation and the declaration
|
|
852
|
+
* that strands it — can only overlap if a premise here is wrong, so an overlap
|
|
853
|
+
* withdraws the fix rather than throwing at apply time.
|
|
854
|
+
*/
|
|
855
|
+
function isDisjoint(edits) {
|
|
856
|
+
const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
|
|
857
|
+
return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The edits a single fix makes for `batch`: the annotations themselves plus the
|
|
861
|
+
* bindings they were the last consumers of. `null` when a binding the removal
|
|
779
862
|
* orphans cannot be unbound safely — the caller then drops the fix rather than
|
|
780
863
|
* emitting the half of it that leaves a binding behind.
|
|
864
|
+
*
|
|
865
|
+
* A cleanup span is REPLACED by the comments it covers rather than deleted
|
|
866
|
+
* outright. The planner computes spans that reach across separators, so a
|
|
867
|
+
* comment among the specifiers sits inside one; declining on that comment is no
|
|
868
|
+
* remedy, since it lets a comment decide whether the annotations are stripped at
|
|
869
|
+
* all, which is a comment changing the transform just the same (#1877).
|
|
781
870
|
*/
|
|
782
|
-
function planRemoval(source, batch) {
|
|
871
|
+
function planRemoval(source, removalSource, batch) {
|
|
783
872
|
const annotations = batch.map((entry) => entry.returnType.range);
|
|
784
|
-
const
|
|
785
|
-
|
|
873
|
+
const cleanups = (0, importRemoval_1.planOrphanedBindingRemoval)(removalSource, annotations, (variables, planned) => (0, typeDeclarationRemoval_1.planTypeDeclarationRemoval)(removalSource, variables, planned));
|
|
874
|
+
if (!cleanups)
|
|
875
|
+
return null;
|
|
876
|
+
const edits = annotations.map((range) => ({ range, text: '' }));
|
|
877
|
+
for (const range of cleanups) {
|
|
878
|
+
if (removesWholeStatement(source, range)) {
|
|
879
|
+
edits.push({ range, text: '' });
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const carried = carriedText(source, range);
|
|
883
|
+
if (carried === null)
|
|
884
|
+
return null;
|
|
885
|
+
edits.push({ range, text: carried });
|
|
886
|
+
}
|
|
887
|
+
return isDisjoint(edits) ? edits : null;
|
|
786
888
|
}
|
|
787
889
|
exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
788
890
|
name: 'no-explicit-return-type',
|
|
@@ -822,6 +924,35 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
822
924
|
const sourceCode = context.getSourceCode();
|
|
823
925
|
const visitorKeys = sourceCode.visitorKeys;
|
|
824
926
|
const pending = [];
|
|
927
|
+
/**
|
|
928
|
+
* The source the removal planner reads, with the comments inside a
|
|
929
|
+
* declaration hidden from it.
|
|
930
|
+
*
|
|
931
|
+
* `planImportBindingRemoval` declines outright on any comment inside the
|
|
932
|
+
* declaration it is asked to edit, because the spans it computes reach
|
|
933
|
+
* across separators and a comment nested among the specifiers would be
|
|
934
|
+
* swallowed. That decline is not available here: the annotations that
|
|
935
|
+
* jointly name an import are stripped by one fix or not at all, so
|
|
936
|
+
* declining only the import half strands the binding, and declining the
|
|
937
|
+
* whole fix lets a comment decide whether the rule's own transform fires
|
|
938
|
+
* (#1877, #1902).
|
|
939
|
+
*
|
|
940
|
+
* The planner is therefore asked for the spans as if the declaration
|
|
941
|
+
* carried no comments, and {@link carriedText} re-emits every comment
|
|
942
|
+
* those spans cover in place of the text they delete. Only
|
|
943
|
+
* `getCommentsInside` is blinded: `getCommentsBefore` still answers for
|
|
944
|
+
* the directive that binds a whole statement, which is the one shape no
|
|
945
|
+
* re-emission can save.
|
|
946
|
+
*/
|
|
947
|
+
const removalSource = {
|
|
948
|
+
text: sourceCode.text,
|
|
949
|
+
ast: sourceCode.ast,
|
|
950
|
+
scopeManager: sourceCode.scopeManager,
|
|
951
|
+
getTokenBefore: sourceCode.getTokenBefore.bind(sourceCode),
|
|
952
|
+
getTokenAfter: sourceCode.getTokenAfter.bind(sourceCode),
|
|
953
|
+
getCommentsBefore: sourceCode.getCommentsBefore.bind(sourceCode),
|
|
954
|
+
getCommentsInside: () => [],
|
|
955
|
+
};
|
|
825
956
|
/**
|
|
826
957
|
* Whether ESLint will discard a report, resolved the way ESLint resolves
|
|
827
958
|
* it. A fix that deletes several annotations at once is counting on all of
|
|
@@ -884,9 +1015,9 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
884
1015
|
pending.push({ node, returnType, strippable });
|
|
885
1016
|
}
|
|
886
1017
|
/**
|
|
887
|
-
* Emits every held report, each carrying the
|
|
888
|
-
* removal makes necessary. An annotation and the
|
|
889
|
-
* fix: applying either half alone leaves the file worse than applying
|
|
1018
|
+
* Emits every held report, each carrying the binding cleanup its own
|
|
1019
|
+
* removal makes necessary. An annotation and the binding it unbinds are
|
|
1020
|
+
* one fix: applying either half alone leaves the file worse than applying
|
|
890
1021
|
* neither.
|
|
891
1022
|
*
|
|
892
1023
|
* Orphanhood is judged against a single fix's own deletions, never against
|
|
@@ -899,18 +1030,20 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
899
1030
|
* Waiting for the last such annotation does not work either. Once every
|
|
900
1031
|
* annotation is stripped the rule has nothing left to report, so no later
|
|
901
1032
|
* fix exists to carry the cleanup and the binding stays orphaned for good
|
|
902
|
-
* (issue #1654). The annotations that jointly hold
|
|
1033
|
+
* (issue #1654). The annotations that jointly hold a binding alive are
|
|
903
1034
|
* therefore removed by one fix, which owes two things: it deletes all of
|
|
904
1035
|
* them itself rather than assuming sibling reports land, and it counts
|
|
905
1036
|
* only annotations whose reports ESLint will not discard.
|
|
906
1037
|
*/
|
|
907
1038
|
function flushReports() {
|
|
908
1039
|
const strippable = pending.filter((entry) => entry.strippable);
|
|
909
|
-
//
|
|
910
|
-
//
|
|
1040
|
+
// Each annotation starts with the judgement it would get alone. That
|
|
1041
|
+
// judgement holds only while the annotation is the whole story for every
|
|
1042
|
+
// binding it names; a batch below either extends it to cover the
|
|
1043
|
+
// binding they share, or withdraws it.
|
|
911
1044
|
const plans = new Map();
|
|
912
1045
|
for (const entry of strippable) {
|
|
913
|
-
const plan = planRemoval(sourceCode, [entry]);
|
|
1046
|
+
const plan = planRemoval(sourceCode, removalSource, [entry]);
|
|
914
1047
|
if (plan) {
|
|
915
1048
|
plans.set(entry, plan);
|
|
916
1049
|
}
|
|
@@ -919,11 +1052,21 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
919
1052
|
for (const batch of batches) {
|
|
920
1053
|
if (batch.length < 2)
|
|
921
1054
|
continue;
|
|
922
|
-
const plan = planRemoval(sourceCode, batch);
|
|
923
|
-
if (
|
|
1055
|
+
const plan = planRemoval(sourceCode, removalSource, batch);
|
|
1056
|
+
if (plan) {
|
|
1057
|
+
for (const entry of batch) {
|
|
1058
|
+
plans.set(entry, plan);
|
|
1059
|
+
}
|
|
924
1060
|
continue;
|
|
1061
|
+
}
|
|
1062
|
+
// The batch exists because its members jointly hold a binding alive,
|
|
1063
|
+
// so falling back to the one-at-a-time strip is not the harmless
|
|
1064
|
+
// status quo it looks like: `--fix` applies those strips in the same
|
|
1065
|
+
// run, the last one leaves the binding referenced by nothing, and no
|
|
1066
|
+
// report survives to clean it up (#1902). Every member gives up its
|
|
1067
|
+
// fix and keeps its report.
|
|
925
1068
|
for (const entry of batch) {
|
|
926
|
-
plans.
|
|
1069
|
+
plans.delete(entry);
|
|
927
1070
|
}
|
|
928
1071
|
}
|
|
929
1072
|
for (const entry of pending) {
|
|
@@ -936,7 +1079,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
936
1079
|
data: { functionKind: describeFunctionKind(entry.node) },
|
|
937
1080
|
...(plan
|
|
938
1081
|
? {
|
|
939
|
-
fix: (fixer) => plan.map((
|
|
1082
|
+
fix: (fixer) => plan.map((edit) => fixer.replaceTextRange([edit.range[0], edit.range[1]], edit.text)),
|
|
940
1083
|
}
|
|
941
1084
|
: {}),
|
|
942
1085
|
});
|
|
@@ -11,8 +11,10 @@ const createRule_1 = require("../utils/createRule");
|
|
|
11
11
|
const FIREBASE_ADMIN_MODULE = 'firebaseAdmin';
|
|
12
12
|
const MODULE_EXTENSION = /\.(?:tsx?|jsx?|mjs|cjs)$/;
|
|
13
13
|
/**
|
|
14
|
-
* Backend tier: `jest.setup.node.js`
|
|
15
|
-
*
|
|
14
|
+
* Backend tier: `jest.setup.node.js` runs a bare
|
|
15
|
+
* `jest.mock('./functions/src/config/firebaseAdmin')`, which activates the
|
|
16
|
+
* manual mock in `functions/src/config/__mocks__/`. A local *factory* replaces
|
|
17
|
+
* that manual mock and is what the rule exists to catch.
|
|
16
18
|
*/
|
|
17
19
|
const BACKEND_FIREBASE_ADMIN = 'functions/src/config/firebaseAdmin';
|
|
18
20
|
/**
|
|
@@ -35,19 +37,60 @@ const comparisonPathOf = (mockPath, filename) => {
|
|
|
35
37
|
}
|
|
36
38
|
return toPosix(path_1.default.resolve(path_1.default.dirname(filename), mockPath));
|
|
37
39
|
};
|
|
38
|
-
const
|
|
40
|
+
const tierOf = (comparisonPath) => {
|
|
39
41
|
// Backend is tested first because the backend path *contains* the frontend
|
|
40
42
|
// path as a substring; checking frontend first would exempt every backend
|
|
41
43
|
// mock and silently disable the rule.
|
|
42
44
|
if (comparisonPath.includes(BACKEND_FIREBASE_ADMIN)) {
|
|
43
|
-
return
|
|
45
|
+
return 'backend';
|
|
44
46
|
}
|
|
45
47
|
if (comparisonPath.includes(FRONTEND_FIREBASE_ADMIN)) {
|
|
46
|
-
return
|
|
48
|
+
return 'frontend';
|
|
47
49
|
}
|
|
48
50
|
// A specifier attributable to neither tier keeps the rule's protection:
|
|
49
51
|
// prefer reporting over silently allowing an unknown layout to shadow a
|
|
50
52
|
// shared mock.
|
|
53
|
+
return 'unknown';
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The factory argument, or `undefined` when the call supplies none. An explicit
|
|
57
|
+
* `undefined` placeholder — `jest.mock(path, undefined, { virtual: true })` — is
|
|
58
|
+
* the same call as the one-argument form, so it must read as "no factory" too.
|
|
59
|
+
*/
|
|
60
|
+
const factoryOf = (node) => {
|
|
61
|
+
const factory = node.arguments[1];
|
|
62
|
+
if (!factory) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (factory.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
66
|
+
factory.name === 'undefined') {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return factory;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether the call overrides the shared mock rather than re-activating it.
|
|
73
|
+
*
|
|
74
|
+
* A BARE backend `jest.mock()` is byte-for-byte the call `jest.setup.node.js`
|
|
75
|
+
* already makes: it activates the manual mock at
|
|
76
|
+
* `functions/src/config/__mocks__/firebaseAdmin.ts`. It replaces nothing and
|
|
77
|
+
* supplies no divergent state, so the message's rationale simply does not apply
|
|
78
|
+
* — and suites need the local form to obtain per-call spies (`db.doc`,
|
|
79
|
+
* `db.runTransaction`) that the shared `FakeFirestore` instance never exposes.
|
|
80
|
+
* Only a factory substitutes a different module body.
|
|
81
|
+
*
|
|
82
|
+
* The exemption stops at the backend tier on purpose. For an unattributable
|
|
83
|
+
* specifier there is no known `__mocks__` sibling, so a bare call may fall
|
|
84
|
+
* through to Jest's AUTOMOCK — every export replaced by an empty `jest.fn()` —
|
|
85
|
+
* which genuinely is divergent state.
|
|
86
|
+
*/
|
|
87
|
+
const overridesSharedMock = (tier, factory) => {
|
|
88
|
+
if (tier === 'frontend') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (tier === 'backend') {
|
|
92
|
+
return factory !== undefined;
|
|
93
|
+
}
|
|
51
94
|
return true;
|
|
52
95
|
};
|
|
53
96
|
/**
|
|
@@ -126,7 +169,7 @@ exports.noMockFirebaseAdmin = (0, createRule_1.createRule)({
|
|
|
126
169
|
},
|
|
127
170
|
schema: [],
|
|
128
171
|
messages: {
|
|
129
|
-
noMockFirebaseAdmin: 'Do not
|
|
172
|
+
noMockFirebaseAdmin: 'Do not override the firebaseAdmin module mock for "{{modulePath}}". jest.setup.node.js already activates the shared manual mock; substituting your own module body creates divergent Firestore/Auth state and brittle test fixtures. Use __test-utils__/mockFirestore to seed data, and where a suite only needs the shared mock active, write a bare jest.mock() of functions/src/config/firebaseAdmin with no factory argument.',
|
|
130
173
|
},
|
|
131
174
|
},
|
|
132
175
|
defaultOptions: [],
|
|
@@ -165,10 +208,11 @@ exports.noMockFirebaseAdmin = (0, createRule_1.createRule)({
|
|
|
165
208
|
if (!targetsFirebaseAdmin(mockPath)) {
|
|
166
209
|
return;
|
|
167
210
|
}
|
|
168
|
-
|
|
211
|
+
const tier = tierOf(comparisonPathOf(mockPath, filename));
|
|
212
|
+
const factory = factoryOf(node);
|
|
213
|
+
if (!overridesSharedMock(tier, factory)) {
|
|
169
214
|
return;
|
|
170
215
|
}
|
|
171
|
-
const factory = node.arguments[1];
|
|
172
216
|
if (factory && exercisesCollectionGroup(factory)) {
|
|
173
217
|
return;
|
|
174
218
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
1
2
|
type Options = [
|
|
2
3
|
{
|
|
3
4
|
memoizedHookNames?: string[];
|
|
4
5
|
assumeAllUseAreMemoized?: boolean;
|
|
5
6
|
}
|
|
6
7
|
];
|
|
7
|
-
export declare const noRedundantUseCallbackWrapper:
|
|
8
|
+
export declare const noRedundantUseCallbackWrapper: TSESLint.RuleModule<"redundantWrapper", Options, TSESLint.RuleListener>;
|
|
8
9
|
export default noRedundantUseCallbackWrapper;
|
|
@@ -4,6 +4,11 @@ exports.noRedundantUseCallbackWrapper = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
9
|
+
function rangesOverlap(a, b) {
|
|
10
|
+
return a[0] < b[1] && b[0] < a[1];
|
|
11
|
+
}
|
|
7
12
|
const LATEST_CALLBACK_MODULE = 'use-latest-callback';
|
|
8
13
|
const LATEST_CALLBACK_HOOK = 'useLatestCallback';
|
|
9
14
|
const MEMO_HOOK = 'useMemo';
|
|
@@ -290,6 +295,86 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
290
295
|
// Track identifiers coming from hook-like calls
|
|
291
296
|
const hookReturnObjects = new Set(); // variables assigned to a hook call result (object or function)
|
|
292
297
|
const hookReturnProps = new Set(); // properties destructured from a hook call result
|
|
298
|
+
/**
|
|
299
|
+
* Every redundant wrapper the rule finds, in traversal order.
|
|
300
|
+
*
|
|
301
|
+
* Reporting is deferred to `Program:exit` because the wrapper's import is
|
|
302
|
+
* unbound only once no surviving call references it. Judged one call at a
|
|
303
|
+
* time, a file with two wrappers never sees either as the binding's last
|
|
304
|
+
* use, and the pass that collapses both resolves every report — so nothing
|
|
305
|
+
* ever revisits the stranded import.
|
|
306
|
+
*/
|
|
307
|
+
const violations = [];
|
|
308
|
+
/**
|
|
309
|
+
* A suppressed report is dropped together with its fix, so its rewrite
|
|
310
|
+
* never happens: counting it toward the batch would unbind an import the
|
|
311
|
+
* surviving text still calls.
|
|
312
|
+
*/
|
|
313
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
314
|
+
/**
|
|
315
|
+
* A wrapper collapses to the delegate's bare name, so the delegate is the
|
|
316
|
+
* only text the rewrite keeps. Everything else inside the call — the
|
|
317
|
+
* wrapper's callee, the callback's syntax, the dependency array — is
|
|
318
|
+
* genuinely deleted, and only that may be handed to the orphan planner: a
|
|
319
|
+
* span covering the delegate would read whatever it names as unreferenced
|
|
320
|
+
* and delete that import too, an over-removal strictly worse than the
|
|
321
|
+
* stranded import this exists to prevent.
|
|
322
|
+
*/
|
|
323
|
+
const planViolation = (violation, delegate) => ({
|
|
324
|
+
violation,
|
|
325
|
+
edit: { range: violation.node.range, text: delegate.name },
|
|
326
|
+
removed: [
|
|
327
|
+
[violation.node.range[0], delegate.range[0]],
|
|
328
|
+
[delegate.range[1], violation.node.range[1]],
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
/**
|
|
332
|
+
* The rewrites that actually ship, in traversal order.
|
|
333
|
+
*
|
|
334
|
+
* Each is screened alone before it joins the batch: a rewrite whose own
|
|
335
|
+
* deletion orphans something that cannot be unbound safely — a local
|
|
336
|
+
* variable, an import behind a directive comment — would otherwise poison
|
|
337
|
+
* every other rewrite in the file. Edits colliding with an already accepted
|
|
338
|
+
* one are dropped for the same reason a single report's overlapping fixes
|
|
339
|
+
* are: ESLint asserts on the overlap and discards every message for the
|
|
340
|
+
* file. A nested pair collides this way, and the enclosing rewrite wins
|
|
341
|
+
* because it is visited first; the inner one is fixed on a later pass.
|
|
342
|
+
*/
|
|
343
|
+
const planViolations = () => {
|
|
344
|
+
const planned = [];
|
|
345
|
+
const claimed = [];
|
|
346
|
+
for (const violation of violations) {
|
|
347
|
+
if (!violation.delegate)
|
|
348
|
+
continue;
|
|
349
|
+
if (isReportSuppressed(violation.node))
|
|
350
|
+
continue;
|
|
351
|
+
const candidate = planViolation(violation, violation.delegate);
|
|
352
|
+
if ((0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, candidate.removed) === null) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (claimed.some((taken) => rangesOverlap(candidate.edit.range, taken))) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
claimed.push(candidate.edit.range);
|
|
359
|
+
planned.push(candidate);
|
|
360
|
+
}
|
|
361
|
+
return planned;
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* Records a redundant wrapper without reporting it yet.
|
|
365
|
+
*
|
|
366
|
+
* A member delegate (`ctx.submit`) is recorded unfixed: rewriting the
|
|
367
|
+
* wrapper to a bare member reference would detach the callback from its
|
|
368
|
+
* receiver and change what `this` binds to.
|
|
369
|
+
*/
|
|
370
|
+
const recordViolation = (node, wrapper, delegate) => {
|
|
371
|
+
violations.push({
|
|
372
|
+
node,
|
|
373
|
+
wrapper,
|
|
374
|
+
callbackName: sourceCode.getText(delegate),
|
|
375
|
+
delegate: delegate.type === utils_1.AST_NODE_TYPES.Identifier ? delegate : null,
|
|
376
|
+
});
|
|
377
|
+
};
|
|
293
378
|
return {
|
|
294
379
|
ImportDeclaration(node) {
|
|
295
380
|
// The module's sole export is the hook, so its DEFAULT specifier binds
|
|
@@ -364,29 +449,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
364
449
|
(unwrappedArg.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
365
450
|
unwrappedArg.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
366
451
|
hookReturnObjects.has(unwrappedArg.object.name))) {
|
|
367
|
-
|
|
368
|
-
const replaceText = unwrappedArg.name;
|
|
369
|
-
context.report({
|
|
370
|
-
node,
|
|
371
|
-
messageId: 'redundantWrapper',
|
|
372
|
-
data: {
|
|
373
|
-
wrapper,
|
|
374
|
-
callbackName: sourceCode.getText(unwrappedArg),
|
|
375
|
-
},
|
|
376
|
-
fix: (fixer) => fixer.replaceText(node, replaceText),
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
else {
|
|
380
|
-
// Member function — report only, no fix to avoid breaking `this`.
|
|
381
|
-
context.report({
|
|
382
|
-
node,
|
|
383
|
-
messageId: 'redundantWrapper',
|
|
384
|
-
data: {
|
|
385
|
-
wrapper,
|
|
386
|
-
callbackName: sourceCode.getText(unwrappedArg),
|
|
387
|
-
},
|
|
388
|
-
});
|
|
389
|
-
}
|
|
452
|
+
recordViolation(node, wrapper, unwrappedArg);
|
|
390
453
|
}
|
|
391
454
|
return;
|
|
392
455
|
}
|
|
@@ -417,29 +480,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
417
480
|
return;
|
|
418
481
|
}
|
|
419
482
|
else {
|
|
420
|
-
|
|
421
|
-
const replaceText = callee.name;
|
|
422
|
-
context.report({
|
|
423
|
-
node,
|
|
424
|
-
messageId: 'redundantWrapper',
|
|
425
|
-
data: {
|
|
426
|
-
wrapper,
|
|
427
|
-
callbackName: sourceCode.getText(callee),
|
|
428
|
-
},
|
|
429
|
-
fix: (fixer) => fixer.replaceText(node, replaceText),
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
else {
|
|
433
|
-
// Member function — report only, no fix to avoid breaking `this`.
|
|
434
|
-
context.report({
|
|
435
|
-
node,
|
|
436
|
-
messageId: 'redundantWrapper',
|
|
437
|
-
data: {
|
|
438
|
-
wrapper,
|
|
439
|
-
callbackName: sourceCode.getText(callee),
|
|
440
|
-
},
|
|
441
|
-
});
|
|
442
|
-
}
|
|
483
|
+
recordViolation(node, wrapper, callee);
|
|
443
484
|
}
|
|
444
485
|
}
|
|
445
486
|
}
|
|
@@ -490,30 +531,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
490
531
|
}
|
|
491
532
|
else {
|
|
492
533
|
// No args and trivial wrapper
|
|
493
|
-
|
|
494
|
-
const replaceText = callee
|
|
495
|
-
.name;
|
|
496
|
-
context.report({
|
|
497
|
-
node,
|
|
498
|
-
messageId: 'redundantWrapper',
|
|
499
|
-
data: {
|
|
500
|
-
wrapper,
|
|
501
|
-
callbackName: sourceCode.getText(callee),
|
|
502
|
-
},
|
|
503
|
-
fix: (fixer) => fixer.replaceText(node, replaceText),
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
// Member function — report only, no fix to avoid breaking `this`.
|
|
508
|
-
context.report({
|
|
509
|
-
node,
|
|
510
|
-
messageId: 'redundantWrapper',
|
|
511
|
-
data: {
|
|
512
|
-
wrapper,
|
|
513
|
-
callbackName: sourceCode.getText(callee),
|
|
514
|
-
},
|
|
515
|
-
});
|
|
516
|
-
}
|
|
534
|
+
recordViolation(node, wrapper, callee);
|
|
517
535
|
}
|
|
518
536
|
}
|
|
519
537
|
}
|
|
@@ -523,6 +541,44 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
523
541
|
}
|
|
524
542
|
}
|
|
525
543
|
},
|
|
544
|
+
'Program:exit'() {
|
|
545
|
+
if (violations.length === 0)
|
|
546
|
+
return;
|
|
547
|
+
const planned = planViolations();
|
|
548
|
+
// One plan over every surviving rewrite: the wrapper's binding is left
|
|
549
|
+
// unreferenced by their union even when no single collapse strips its
|
|
550
|
+
// last use, and the pass that applies them all resolves every report —
|
|
551
|
+
// so this is the only moment the stranded import is visible.
|
|
552
|
+
const importRemoval = planned.length > 0
|
|
553
|
+
? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed))
|
|
554
|
+
: null;
|
|
555
|
+
// The whole batch ships as one fix, so no collapse can land without the
|
|
556
|
+
// others the import's orphanhood was judged against, and no unbinding
|
|
557
|
+
// can land without the collapse it was claimed on. The other violations
|
|
558
|
+
// report without a fixer; the carrier's pass already resolves them.
|
|
559
|
+
//
|
|
560
|
+
// No plan at all means some binding would be left unreferenced yet
|
|
561
|
+
// cannot be unbound safely, so every collapse stays behind: reports
|
|
562
|
+
// without a fixer are the lesser damage.
|
|
563
|
+
const removalRanges = importRemoval ?? [];
|
|
564
|
+
const carrier = importRemoval ? planned[0] : undefined;
|
|
565
|
+
for (const violation of violations) {
|
|
566
|
+
context.report({
|
|
567
|
+
node: violation.node,
|
|
568
|
+
messageId: 'redundantWrapper',
|
|
569
|
+
data: {
|
|
570
|
+
wrapper: violation.wrapper,
|
|
571
|
+
callbackName: violation.callbackName,
|
|
572
|
+
},
|
|
573
|
+
fix: violation === carrier?.violation
|
|
574
|
+
? (fixer) => [
|
|
575
|
+
...removalRanges.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
576
|
+
...planned.map((entry) => fixer.replaceTextRange(entry.edit.range, entry.edit.text)),
|
|
577
|
+
]
|
|
578
|
+
: undefined,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
},
|
|
526
582
|
};
|
|
527
583
|
},
|
|
528
584
|
});
|