@blumintinc/eslint-plugin-blumint 1.16.2 → 1.17.0
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/README.md +21 -0
- package/lib/index.js +64 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +30 -14
- package/lib/rules/enforce-cloud-function-id-length.d.ts +5 -0
- package/lib/rules/enforce-cloud-function-id-length.js +104 -0
- package/lib/rules/enforce-is-prefix-validators.d.ts +13 -0
- package/lib/rules/enforce-is-prefix-validators.js +304 -0
- package/lib/rules/enforce-m3-sentence-case.d.ts +12 -0
- package/lib/rules/enforce-m3-sentence-case.js +430 -0
- package/lib/rules/enforce-snapshot-state-narrowing.d.ts +10 -0
- package/lib/rules/enforce-snapshot-state-narrowing.js +281 -0
- package/lib/rules/enforce-types-directory-placement.d.ts +9 -0
- package/lib/rules/enforce-types-directory-placement.js +276 -0
- package/lib/rules/no-direct-function-state.d.ts +8 -0
- package/lib/rules/no-direct-function-state.js +285 -0
- package/lib/rules/no-fill-template-mutation.d.ts +1 -0
- package/lib/rules/no-fill-template-mutation.js +324 -0
- package/lib/rules/no-portal-inside-tooltip.d.ts +10 -0
- package/lib/rules/no-portal-inside-tooltip.js +219 -0
- package/lib/rules/no-redundant-boolean-callback-props.d.ts +11 -0
- package/lib/rules/no-redundant-boolean-callback-props.js +355 -0
- package/lib/rules/no-satisfies-in-frontend-bundle.d.ts +8 -0
- package/lib/rules/no-satisfies-in-frontend-bundle.js +126 -0
- package/lib/rules/no-single-dismiss-dialog-button.d.ts +7 -0
- package/lib/rules/no-single-dismiss-dialog-button.js +127 -0
- package/lib/rules/no-stablehash-react-nodes.d.ts +1 -0
- package/lib/rules/no-stablehash-react-nodes.js +325 -0
- package/lib/rules/parallelize-loop-awaits.d.ts +8 -0
- package/lib/rules/parallelize-loop-awaits.js +582 -0
- package/lib/rules/prefer-flat-transform-each-keys.d.ts +1 -0
- package/lib/rules/prefer-flat-transform-each-keys.js +228 -0
- package/lib/rules/prefer-getter-over-parameterless-method.d.ts +1 -0
- package/lib/rules/prefer-getter-over-parameterless-method.js +44 -0
- package/lib/rules/prefer-spread-over-reassembly.d.ts +5 -0
- package/lib/rules/prefer-spread-over-reassembly.js +401 -0
- package/lib/rules/prefer-sx-prop-over-system-props.d.ts +9 -0
- package/lib/rules/prefer-sx-prop-over-system-props.js +401 -0
- package/lib/rules/prefer-use-base62-id.d.ts +8 -0
- package/lib/rules/prefer-use-base62-id.js +483 -0
- package/lib/rules/prefer-use-theme.d.ts +1 -0
- package/lib/rules/prefer-use-theme.js +206 -0
- package/lib/rules/prefer-utility-function-own-file.d.ts +9 -0
- package/lib/rules/prefer-utility-function-own-file.js +505 -0
- package/lib/rules/require-props-composition.d.ts +10 -0
- package/lib/rules/require-props-composition.js +433 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.d.ts +9 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.js +313 -0
- package/package.json +1 -1
- package/release-manifest.json +190 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.noPortalInsideTooltip = void 0;
|
|
4
|
+
const createRule_1 = require("../utils/createRule");
|
|
5
|
+
const DEFAULT_TOOLTIP_COMPONENTS = [
|
|
6
|
+
'Tooltip',
|
|
7
|
+
'WYSIWYGTooltip',
|
|
8
|
+
'MatchPayoutTooltip',
|
|
9
|
+
'TeamDisplayTooltip',
|
|
10
|
+
'OptionalTooltip',
|
|
11
|
+
'ComingSoonTooltip',
|
|
12
|
+
'TooltipDynamicDelay',
|
|
13
|
+
'ValidationTooltip',
|
|
14
|
+
];
|
|
15
|
+
const DEFAULT_PORTAL_COMPONENTS = [
|
|
16
|
+
'Dialog',
|
|
17
|
+
'Drawer',
|
|
18
|
+
'Menu',
|
|
19
|
+
'Popover',
|
|
20
|
+
'Portal',
|
|
21
|
+
'Modal',
|
|
22
|
+
'WizardPortal',
|
|
23
|
+
'DialogCentered',
|
|
24
|
+
'AlertDialog',
|
|
25
|
+
];
|
|
26
|
+
const PORTAL_SUFFIXES = ['Portal', 'Dialog', 'Drawer', 'Menu', 'Popover'];
|
|
27
|
+
/**
|
|
28
|
+
* Extracts the string name from a JSX element's opening tag name expression.
|
|
29
|
+
* Returns null for member expressions (e.g. Foo.Bar) since they're out of scope.
|
|
30
|
+
*/
|
|
31
|
+
function getJsxElementName(nameExpr) {
|
|
32
|
+
if (nameExpr.type === 'JSXIdentifier') {
|
|
33
|
+
return nameExpr.name;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Recursively searches a node's JSX descendant tree for portal components,
|
|
39
|
+
* including those nested inside expression containers (logical/conditional
|
|
40
|
+
* expressions), fragments, and regular elements.
|
|
41
|
+
*
|
|
42
|
+
* Returns the first portal node found, or null if none.
|
|
43
|
+
*/
|
|
44
|
+
function findPortalInDescendants(node, isPortalComponent) {
|
|
45
|
+
if (node.type === 'JSXElement') {
|
|
46
|
+
const name = getJsxElementName(node.openingElement.name);
|
|
47
|
+
if (name !== null && isPortalComponent(name)) {
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
// Recurse into JSXElement children
|
|
51
|
+
for (const child of node.children) {
|
|
52
|
+
const found = findPortalInDescendants(child, isPortalComponent);
|
|
53
|
+
if (found)
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (node.type === 'JSXFragment') {
|
|
59
|
+
// Treat fragments as transparent — recurse into their children
|
|
60
|
+
for (const child of node.children) {
|
|
61
|
+
const found = findPortalInDescendants(child, isPortalComponent);
|
|
62
|
+
if (found)
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
68
|
+
return findPortalInExpression(node.expression, isPortalComponent);
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Descends into expression nodes (logical, conditional) looking for portal
|
|
74
|
+
* JSX elements. Call expressions and other dynamic shapes are out of scope
|
|
75
|
+
* and silently produce no match (avoiding false positives).
|
|
76
|
+
*/
|
|
77
|
+
function findPortalInExpression(expr, isPortalComponent) {
|
|
78
|
+
if (expr.type === 'JSXElement') {
|
|
79
|
+
return findPortalInDescendants(expr, isPortalComponent);
|
|
80
|
+
}
|
|
81
|
+
if (expr.type === 'JSXFragment') {
|
|
82
|
+
return findPortalInDescendants(expr, isPortalComponent);
|
|
83
|
+
}
|
|
84
|
+
if (expr.type === 'LogicalExpression') {
|
|
85
|
+
// Both sides could be JSX — check the right operand (the render branch)
|
|
86
|
+
// and also the left in case of || chaining
|
|
87
|
+
const rightResult = findPortalInExpression(expr.right, isPortalComponent);
|
|
88
|
+
if (rightResult)
|
|
89
|
+
return rightResult;
|
|
90
|
+
return findPortalInExpression(expr.left, isPortalComponent);
|
|
91
|
+
}
|
|
92
|
+
if (expr.type === 'ConditionalExpression') {
|
|
93
|
+
const consequentResult = findPortalInExpression(expr.consequent, isPortalComponent);
|
|
94
|
+
if (consequentResult)
|
|
95
|
+
return consequentResult;
|
|
96
|
+
return findPortalInExpression(expr.alternate, isPortalComponent);
|
|
97
|
+
}
|
|
98
|
+
if (expr.type === 'Identifier') {
|
|
99
|
+
// Handle {Portal} — an Identifier whose name matches a portal component name
|
|
100
|
+
if (isPortalComponent(expr.name)) {
|
|
101
|
+
return expr;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// CallExpression, TemplateLiteral, etc. are out of scope — return null
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
exports.noPortalInsideTooltip = (0, createRule_1.createRule)({
|
|
108
|
+
name: 'no-portal-inside-tooltip',
|
|
109
|
+
meta: {
|
|
110
|
+
type: 'problem',
|
|
111
|
+
docs: {
|
|
112
|
+
description: 'Disallow portal-rendering components (Dialog, Drawer, Menu, Popover, Portal, Modal) inside Tooltip wrapper components. React Portals preserve React-tree event bubbling, so a portal nested under a Tooltip leaves the tooltip orphaned over the modal.',
|
|
113
|
+
recommended: 'error',
|
|
114
|
+
},
|
|
115
|
+
schema: [
|
|
116
|
+
{
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
tooltipComponents: {
|
|
120
|
+
type: 'array',
|
|
121
|
+
items: { type: 'string' },
|
|
122
|
+
description: 'Additional tooltip wrapper component names to detect.',
|
|
123
|
+
},
|
|
124
|
+
portalComponents: {
|
|
125
|
+
type: 'array',
|
|
126
|
+
items: { type: 'string' },
|
|
127
|
+
description: 'Additional portal-rendering component names to detect.',
|
|
128
|
+
},
|
|
129
|
+
detectTooltipSuffix: {
|
|
130
|
+
type: 'boolean',
|
|
131
|
+
default: true,
|
|
132
|
+
description: 'When true, any component whose name ends in "Tooltip" is treated as a tooltip wrapper.',
|
|
133
|
+
},
|
|
134
|
+
detectPortalSuffix: {
|
|
135
|
+
type: 'boolean',
|
|
136
|
+
default: true,
|
|
137
|
+
description: 'When true, any component whose name ends in Portal, Dialog, Drawer, Menu, or Popover is treated as a portal.',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
messages: {
|
|
144
|
+
portalInsideTooltip: "Portal-rendering component '{{portalName}}' must not be nested inside Tooltip wrapper '{{tooltipName}}'. React Portals preserve React-tree event bubbling, so the portal's modal Backdrop becomes a logical descendant of the tooltip wrapper — preventing onMouseLeave from firing and leaving the tooltip orphaned over the modal. Hoist the portal out of the tooltip (use TooltipChipTrigger or render the portal as a sibling).",
|
|
145
|
+
},
|
|
146
|
+
fixable: undefined,
|
|
147
|
+
},
|
|
148
|
+
defaultOptions: [{}],
|
|
149
|
+
create(context) {
|
|
150
|
+
const options = context.options[0] ?? {};
|
|
151
|
+
const detectTooltipSuffix = options.detectTooltipSuffix !== false;
|
|
152
|
+
const detectPortalSuffix = options.detectPortalSuffix !== false;
|
|
153
|
+
// Build the tooltip component name set
|
|
154
|
+
const tooltipSet = new Set(DEFAULT_TOOLTIP_COMPONENTS);
|
|
155
|
+
if (options.tooltipComponents) {
|
|
156
|
+
for (const name of options.tooltipComponents) {
|
|
157
|
+
tooltipSet.add(name);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Build the portal component name set
|
|
161
|
+
const portalSet = new Set(DEFAULT_PORTAL_COMPONENTS);
|
|
162
|
+
if (options.portalComponents) {
|
|
163
|
+
for (const name of options.portalComponents) {
|
|
164
|
+
portalSet.add(name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const isTooltipComponent = (name) => {
|
|
168
|
+
if (tooltipSet.has(name))
|
|
169
|
+
return true;
|
|
170
|
+
if (detectTooltipSuffix && name.endsWith('Tooltip'))
|
|
171
|
+
return true;
|
|
172
|
+
return false;
|
|
173
|
+
};
|
|
174
|
+
const isPortalComponent = (name) => {
|
|
175
|
+
if (portalSet.has(name))
|
|
176
|
+
return true;
|
|
177
|
+
if (detectPortalSuffix) {
|
|
178
|
+
for (const suffix of PORTAL_SUFFIXES) {
|
|
179
|
+
if (name.endsWith(suffix) && name !== suffix)
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
JSXElement(node) {
|
|
187
|
+
const tooltipName = getJsxElementName(node.openingElement.name);
|
|
188
|
+
if (tooltipName === null || !isTooltipComponent(tooltipName)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Only walk the tooltip's children (not its attributes like `title`)
|
|
192
|
+
for (const child of node.children) {
|
|
193
|
+
const portalNode = findPortalInDescendants(child, isPortalComponent);
|
|
194
|
+
if (portalNode) {
|
|
195
|
+
let portalName = 'Portal';
|
|
196
|
+
if (portalNode.type === 'JSXElement') {
|
|
197
|
+
portalName =
|
|
198
|
+
getJsxElementName(portalNode.openingElement.name) ?? 'Portal';
|
|
199
|
+
}
|
|
200
|
+
else if (portalNode.type === 'Identifier') {
|
|
201
|
+
portalName = portalNode.name;
|
|
202
|
+
}
|
|
203
|
+
context.report({
|
|
204
|
+
node: portalNode,
|
|
205
|
+
messageId: 'portalInsideTooltip',
|
|
206
|
+
data: {
|
|
207
|
+
portalName,
|
|
208
|
+
tooltipName,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
// Report only the first portal found per tooltip to avoid flooding
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
//# sourceMappingURL=no-portal-inside-tooltip.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Options = [
|
|
2
|
+
{
|
|
3
|
+
booleanPrefixes?: string[];
|
|
4
|
+
callbackPrefixes?: string[];
|
|
5
|
+
booleanSuffixesToStrip?: string[];
|
|
6
|
+
exemptQualifiers?: string[];
|
|
7
|
+
minNounLengthForSuffixMatch?: number;
|
|
8
|
+
}?
|
|
9
|
+
];
|
|
10
|
+
export declare const noRedundantBooleanCallbackProps: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"redundantBooleanProp", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.noRedundantBooleanCallbackProps = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
/**
|
|
7
|
+
* Default boolean prop prefixes that signal visibility/enablement control —
|
|
8
|
+
* the patterns most likely to be redundant when a matching callback exists.
|
|
9
|
+
* Prefixes like `is`/`has` represent state rather than control, so they are
|
|
10
|
+
* excluded by default to avoid false positives on state props.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_BOOLEAN_PREFIXES = [
|
|
13
|
+
'shouldShow',
|
|
14
|
+
'shouldEnable',
|
|
15
|
+
'shouldAllow',
|
|
16
|
+
'enable',
|
|
17
|
+
'allow',
|
|
18
|
+
'show',
|
|
19
|
+
'hide',
|
|
20
|
+
'display',
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Default callback prefixes to match against the boolean's core noun.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_CALLBACK_PREFIXES = ['on', 'handle'];
|
|
26
|
+
/**
|
|
27
|
+
* Trailing decorative suffixes that do not change the noun being controlled.
|
|
28
|
+
* E.g. `shouldShowMinimizeIcon` — `Icon` is purely decorative; the feature
|
|
29
|
+
* being toggled is still `Minimize`.
|
|
30
|
+
*
|
|
31
|
+
* When suffix stripping IS used to produce a noun match, the rule requires the
|
|
32
|
+
* resulting noun to be at least `minNounLengthForSuffixMatch` characters long
|
|
33
|
+
* to avoid false positives on short generic action words (e.g. `Close`) that
|
|
34
|
+
* appear in many general-purpose callbacks.
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_BOOLEAN_SUFFIXES_TO_STRIP = [
|
|
37
|
+
'Icon',
|
|
38
|
+
'Button',
|
|
39
|
+
'Action',
|
|
40
|
+
'Control',
|
|
41
|
+
'Badge',
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* Minimum noun length when suffix stripping is involved in the match. Short
|
|
45
|
+
* nouns like `Close` (5) are often generic action names used in callbacks that
|
|
46
|
+
* span more than one trigger (e.g. `onClose` handles backdrop, escape, and
|
|
47
|
+
* icon clicks). Requiring at least 6 chars lets specific nouns like `Delete`,
|
|
48
|
+
* `Submit`, `Dismiss` match while excluding single-word generic actions.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_MIN_NOUN_LENGTH_FOR_SUFFIX_MATCH = 6;
|
|
51
|
+
/**
|
|
52
|
+
* Qualifying word-parts whose presence in the boolean name changes its
|
|
53
|
+
* semantics enough that the boolean is NOT a simple feature-presence toggle.
|
|
54
|
+
* E.g. `shouldExpandInitially` is an initial-state control, not feature-
|
|
55
|
+
* presence control, so we do not flag it even if `onExpand` is present.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_EXEMPT_QUALIFIERS = [
|
|
58
|
+
'Initially',
|
|
59
|
+
'OnMount',
|
|
60
|
+
'ByDefault',
|
|
61
|
+
'WhenHovered',
|
|
62
|
+
'WhenFocused',
|
|
63
|
+
'WhenActive',
|
|
64
|
+
'Always',
|
|
65
|
+
'Never',
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* Determine whether a boolean prop name (after stripping its visibility prefix)
|
|
69
|
+
* "matches" a callback noun with enough confidence to flag a redundancy.
|
|
70
|
+
*
|
|
71
|
+
* Two tiers of match:
|
|
72
|
+
* 1. **Exact match** (no suffix stripping): the boolean remainder equals the
|
|
73
|
+
* callback noun exactly — high confidence, no length restriction.
|
|
74
|
+
* 2. **Suffix-strip match**: the boolean remainder starts with the callback
|
|
75
|
+
* noun and the leftover is a recognized decorative suffix. Requires the
|
|
76
|
+
* callback noun to be at least `minNounLength` characters to avoid matching
|
|
77
|
+
* short generic action words.
|
|
78
|
+
*/
|
|
79
|
+
function booleanCoreMatchesCallbackNoun(booleanRemainder, callbackNoun, suffixesToStrip, minNounLengthForSuffixMatch) {
|
|
80
|
+
const lowerRemainder = booleanRemainder.toLowerCase();
|
|
81
|
+
const lowerNoun = callbackNoun.toLowerCase();
|
|
82
|
+
// Tier 1: exact match (case-insensitive) — highest confidence
|
|
83
|
+
if (lowerRemainder === lowerNoun)
|
|
84
|
+
return true;
|
|
85
|
+
// Tier 2: remainder starts with noun + recognized decorative suffix
|
|
86
|
+
if (!lowerRemainder.startsWith(lowerNoun))
|
|
87
|
+
return false;
|
|
88
|
+
if (callbackNoun.length < minNounLengthForSuffixMatch)
|
|
89
|
+
return false;
|
|
90
|
+
const leftover = booleanRemainder.slice(callbackNoun.length);
|
|
91
|
+
for (const suffix of suffixesToStrip) {
|
|
92
|
+
if (leftover === suffix)
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Check whether a boolean prop name contains an exempt qualifier word,
|
|
99
|
+
* indicating that the boolean controls something beyond simple feature
|
|
100
|
+
* presence (e.g. initial state, conditional display).
|
|
101
|
+
*/
|
|
102
|
+
function hasExemptQualifier(name, exemptQualifiers) {
|
|
103
|
+
for (const qualifier of exemptQualifiers) {
|
|
104
|
+
if (name.includes(qualifier))
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Extract the remainder of a boolean prop name after stripping its leading
|
|
111
|
+
* visibility/enablement prefix. Returns null if no matching prefix is found
|
|
112
|
+
* or if the name is entirely the prefix.
|
|
113
|
+
*
|
|
114
|
+
* Longest prefix wins (so `shouldShow` beats `should`).
|
|
115
|
+
*/
|
|
116
|
+
function stripBooleanPrefix(name, prefixes) {
|
|
117
|
+
const sortedPrefixes = [...prefixes].sort((a, b) => b.length - a.length);
|
|
118
|
+
for (const prefix of sortedPrefixes) {
|
|
119
|
+
if (name.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
120
|
+
const remainder = name.slice(prefix.length);
|
|
121
|
+
if (remainder.length === 0)
|
|
122
|
+
return null;
|
|
123
|
+
// Require a camelCase word boundary after the prefix
|
|
124
|
+
if (remainder[0] >= 'A' && remainder[0] <= 'Z')
|
|
125
|
+
return remainder;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Extract the core noun from a callback prop name after stripping its leading
|
|
132
|
+
* callback prefix (e.g. `on` from `onMinimize` → `Minimize`). Returns null
|
|
133
|
+
* if no matching prefix is found or if the name is entirely the prefix.
|
|
134
|
+
*/
|
|
135
|
+
function extractCallbackNoun(name, callbackPrefixes) {
|
|
136
|
+
const sortedPrefixes = [...callbackPrefixes].sort((a, b) => b.length - a.length);
|
|
137
|
+
for (const prefix of sortedPrefixes) {
|
|
138
|
+
if (name.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
139
|
+
const remainder = name.slice(prefix.length);
|
|
140
|
+
if (remainder.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
if (remainder[0] >= 'A' && remainder[0] <= 'Z')
|
|
143
|
+
return remainder;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Determine if a type annotation node represents a boolean (or a
|
|
150
|
+
* boolean-containing union like `boolean | undefined`).
|
|
151
|
+
*/
|
|
152
|
+
function isBooleanTypeNode(typeAnnotation) {
|
|
153
|
+
if (typeAnnotation.type === utils_1.AST_NODE_TYPES.TSBooleanKeyword)
|
|
154
|
+
return true;
|
|
155
|
+
// Handle `boolean | undefined` / `boolean | null` explicit union forms
|
|
156
|
+
if (typeAnnotation.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
157
|
+
return typeAnnotation.types.every((t) => t.type === utils_1.AST_NODE_TYPES.TSBooleanKeyword ||
|
|
158
|
+
t.type === utils_1.AST_NODE_TYPES.TSUndefinedKeyword ||
|
|
159
|
+
t.type === utils_1.AST_NODE_TYPES.TSNullKeyword);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Determine if a type annotation node represents a callable (function type,
|
|
165
|
+
* named callback type reference, or union involving a function type —
|
|
166
|
+
* including the `callback | 'disabled'` pattern).
|
|
167
|
+
*/
|
|
168
|
+
function isCallableTypeNode(typeAnnotation) {
|
|
169
|
+
if (typeAnnotation.type === utils_1.AST_NODE_TYPES.TSFunctionType)
|
|
170
|
+
return true;
|
|
171
|
+
if (typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference)
|
|
172
|
+
return true;
|
|
173
|
+
if (typeAnnotation.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
174
|
+
return typeAnnotation.types.some((t) => t.type === utils_1.AST_NODE_TYPES.TSFunctionType ||
|
|
175
|
+
t.type === utils_1.AST_NODE_TYPES.TSTypeReference);
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Get the string name of a TSPropertySignature key, or null for computed keys.
|
|
181
|
+
*/
|
|
182
|
+
function getPropName(prop) {
|
|
183
|
+
const key = prop.key;
|
|
184
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier)
|
|
185
|
+
return key.name;
|
|
186
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
187
|
+
return key.value;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Collect all TSPropertySignature members from a type-level node, recursively
|
|
193
|
+
* unwrapping `Readonly<{...}>` wrappers and flattening inline `&` intersection
|
|
194
|
+
* constituent type literals. External type references in intersections are
|
|
195
|
+
* intentionally skipped to avoid flagging inherited library props.
|
|
196
|
+
*/
|
|
197
|
+
function collectMembers(node) {
|
|
198
|
+
const results = [];
|
|
199
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSPropertySignature) {
|
|
200
|
+
results.push(node);
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
204
|
+
for (const member of node.members) {
|
|
205
|
+
if (member.type === utils_1.AST_NODE_TYPES.TSPropertySignature) {
|
|
206
|
+
results.push(member);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return results;
|
|
210
|
+
}
|
|
211
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
212
|
+
for (const constituent of node.types) {
|
|
213
|
+
if (constituent.type === utils_1.AST_NODE_TYPES.TSTypeLiteral ||
|
|
214
|
+
constituent.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
215
|
+
results.push(...collectMembers(constituent));
|
|
216
|
+
}
|
|
217
|
+
else if (constituent.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
218
|
+
// Unwrap `Readonly<{...}>` — recognized safe wrapper
|
|
219
|
+
const ref = constituent;
|
|
220
|
+
if (ref.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
221
|
+
ref.typeName.name === 'Readonly' &&
|
|
222
|
+
ref.typeParameters?.params?.length === 1 &&
|
|
223
|
+
ref.typeParameters.params[0].type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
224
|
+
results.push(...collectMembers(ref.typeParameters.params[0]));
|
|
225
|
+
}
|
|
226
|
+
// Other TSTypeReference in an intersection = external type, skip
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return results;
|
|
230
|
+
}
|
|
231
|
+
// Top-level `Readonly<{...}>` wrapper
|
|
232
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
233
|
+
const ref = node;
|
|
234
|
+
if (ref.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
235
|
+
ref.typeName.name === 'Readonly' &&
|
|
236
|
+
ref.typeParameters?.params?.length === 1 &&
|
|
237
|
+
ref.typeParameters.params[0].type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
238
|
+
return collectMembers(ref.typeParameters.params[0]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return results;
|
|
242
|
+
}
|
|
243
|
+
exports.noRedundantBooleanCallbackProps = (0, createRule_1.createRule)({
|
|
244
|
+
name: 'no-redundant-boolean-callback-props',
|
|
245
|
+
meta: {
|
|
246
|
+
type: 'suggestion',
|
|
247
|
+
docs: {
|
|
248
|
+
description: 'Disallow redundant boolean props that duplicate the semantic meaning of optional callback props, violating the Interface Segregation Principle.',
|
|
249
|
+
recommended: 'error',
|
|
250
|
+
},
|
|
251
|
+
fixable: undefined,
|
|
252
|
+
schema: [
|
|
253
|
+
{
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: {
|
|
256
|
+
booleanPrefixes: {
|
|
257
|
+
type: 'array',
|
|
258
|
+
items: { type: 'string' },
|
|
259
|
+
},
|
|
260
|
+
callbackPrefixes: {
|
|
261
|
+
type: 'array',
|
|
262
|
+
items: { type: 'string' },
|
|
263
|
+
},
|
|
264
|
+
booleanSuffixesToStrip: {
|
|
265
|
+
type: 'array',
|
|
266
|
+
items: { type: 'string' },
|
|
267
|
+
},
|
|
268
|
+
exemptQualifiers: {
|
|
269
|
+
type: 'array',
|
|
270
|
+
items: { type: 'string' },
|
|
271
|
+
},
|
|
272
|
+
minNounLengthForSuffixMatch: {
|
|
273
|
+
type: 'number',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
additionalProperties: false,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
messages: {
|
|
280
|
+
redundantBooleanProp: 'Boolean prop "{{booleanProp}}" is redundant alongside callback prop "{{callbackProp}}" — the callback\'s presence already communicates whether the feature is enabled. Remove "{{booleanProp}}" and key off the callback\'s presence instead, or use the `callback | \'disabled\'` union pattern.',
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
defaultOptions: [{}],
|
|
284
|
+
create(context, [options]) {
|
|
285
|
+
const booleanPrefixes = options?.booleanPrefixes ?? DEFAULT_BOOLEAN_PREFIXES;
|
|
286
|
+
const callbackPrefixes = options?.callbackPrefixes ?? DEFAULT_CALLBACK_PREFIXES;
|
|
287
|
+
const booleanSuffixesToStrip = options?.booleanSuffixesToStrip ?? DEFAULT_BOOLEAN_SUFFIXES_TO_STRIP;
|
|
288
|
+
const exemptQualifiers = options?.exemptQualifiers ?? DEFAULT_EXEMPT_QUALIFIERS;
|
|
289
|
+
const minNounLengthForSuffixMatch = options?.minNounLengthForSuffixMatch ??
|
|
290
|
+
DEFAULT_MIN_NOUN_LENGTH_FOR_SUFFIX_MATCH;
|
|
291
|
+
function checkMembers(members) {
|
|
292
|
+
// Collect boolean candidates: props whose type is boolean and whose name
|
|
293
|
+
// matches a visibility/enablement prefix and has no exempt qualifier.
|
|
294
|
+
const booleanCandidates = [];
|
|
295
|
+
// Map from callback noun (lower-case) → original prop name for reporting
|
|
296
|
+
const callbackNouns = new Map();
|
|
297
|
+
for (const member of members) {
|
|
298
|
+
const name = getPropName(member);
|
|
299
|
+
if (!name || !member.typeAnnotation)
|
|
300
|
+
continue;
|
|
301
|
+
const typeNode = member.typeAnnotation.typeAnnotation;
|
|
302
|
+
if (isBooleanTypeNode(typeNode)) {
|
|
303
|
+
const remainder = stripBooleanPrefix(name, booleanPrefixes);
|
|
304
|
+
if (remainder && !hasExemptQualifier(name, exemptQualifiers)) {
|
|
305
|
+
booleanCandidates.push({ prop: member, name, remainder });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (isCallableTypeNode(typeNode)) {
|
|
309
|
+
const noun = extractCallbackNoun(name, callbackPrefixes);
|
|
310
|
+
if (noun) {
|
|
311
|
+
callbackNouns.set(noun.toLowerCase(), name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// For each boolean candidate, look for a matching callback
|
|
316
|
+
for (const { prop, name, remainder } of booleanCandidates) {
|
|
317
|
+
for (const [callbackNounLower, callbackName] of callbackNouns) {
|
|
318
|
+
// Reconstruct the callback noun with its original casing (first char
|
|
319
|
+
// uppercase) to use for the suffix-match length check.
|
|
320
|
+
const callbackNoun = callbackNounLower.charAt(0).toUpperCase() +
|
|
321
|
+
callbackNounLower.slice(1);
|
|
322
|
+
if (booleanCoreMatchesCallbackNoun(remainder, callbackNoun, booleanSuffixesToStrip, minNounLengthForSuffixMatch)) {
|
|
323
|
+
context.report({
|
|
324
|
+
node: prop,
|
|
325
|
+
messageId: 'redundantBooleanProp',
|
|
326
|
+
data: {
|
|
327
|
+
booleanProp: name,
|
|
328
|
+
callbackProp: callbackName,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
break; // report at most one error per boolean prop
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function analyzeTypeNode(typeNode) {
|
|
337
|
+
const members = collectMembers(typeNode);
|
|
338
|
+
if (members.length > 0) {
|
|
339
|
+
checkMembers(members);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
TSTypeAliasDeclaration(node) {
|
|
344
|
+
analyzeTypeNode(node.typeAnnotation);
|
|
345
|
+
},
|
|
346
|
+
TSInterfaceDeclaration(node) {
|
|
347
|
+
// Analyze the interface body's own members; ignore `extends` clauses
|
|
348
|
+
// (inherited external types are not analyzed to avoid false positives).
|
|
349
|
+
const members = node.body.body.filter((m) => m.type === utils_1.AST_NODE_TYPES.TSPropertySignature);
|
|
350
|
+
checkMembers(members);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
//# sourceMappingURL=no-redundant-boolean-callback-props.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type Options = [
|
|
2
|
+
{
|
|
3
|
+
excludePaths?: string[];
|
|
4
|
+
includePaths?: string[];
|
|
5
|
+
}
|
|
6
|
+
];
|
|
7
|
+
export declare const noSatisfiesInFrontendBundle: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noSatisfiesOperator", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
8
|
+
export {};
|