mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-03 02:53:40 +00:00
a9c8f7431f
This now covers everything that isn't `isObject` or `typeof x === 'number'`. Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
import type { ESTree } from '@oxlint/plugins';
|
|
import { defineRule } from '@oxlint/plugins';
|
|
|
|
/**
|
|
* Operators used when both comparisons assert equality with `null` /
|
|
* `undefined` (`x === null || x === undefined`).
|
|
*/
|
|
const EQUALITY_OPERATORS = new Set(['===', '==']);
|
|
|
|
/**
|
|
* Operators used when both comparisons assert inequality with `null` /
|
|
* `undefined` (`x !== null && x !== undefined`).
|
|
*/
|
|
const INEQUALITY_OPERATORS = new Set(['!==', '!=']);
|
|
|
|
/**
|
|
* Unwrap `ChainExpression` nodes so optional chains (`a?.b`) compare
|
|
* structurally equal to their inner member expression.
|
|
*/
|
|
function unwrapChain(node: ESTree.Node): ESTree.Node {
|
|
return node.type === 'ChainExpression' ? node.expression : node;
|
|
}
|
|
|
|
/**
|
|
* Whether two nodes are the same simple reference: an identifier, `this`, or
|
|
* a member chain of those with identifier or literal keys. Anything with
|
|
* possible side effects (calls, complex computed keys) is not considered.
|
|
*/
|
|
function isSameRef(rawA: ESTree.Node, rawB: ESTree.Node): boolean {
|
|
const a = unwrapChain(rawA);
|
|
const b = unwrapChain(rawB);
|
|
if (a.type !== b.type) {
|
|
return false;
|
|
}
|
|
// the check above already guarantees both nodes have the same type, but the
|
|
// compiler cannot correlate the two narrowings, so `b` is re-checked
|
|
switch (a.type) {
|
|
case 'Identifier':
|
|
return b.type === 'Identifier' && a.name === b.name;
|
|
case 'ThisExpression':
|
|
return true;
|
|
case 'MemberExpression': {
|
|
if (b.type !== 'MemberExpression') {
|
|
return false;
|
|
}
|
|
if (a.computed !== b.computed) {
|
|
return false;
|
|
}
|
|
if (
|
|
a.computed &&
|
|
!(
|
|
a.property.type === 'Literal' &&
|
|
b.property.type === 'Literal' &&
|
|
a.property.value === b.property.value
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
!a.computed &&
|
|
!(
|
|
a.property.type === 'Identifier' &&
|
|
b.property.type === 'Identifier' &&
|
|
a.property.name === b.property.name
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
return isSameRef(a.object, b.object);
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Comparison of a simple reference against a nullish value, as returned by
|
|
* `getNullishComparison`.
|
|
*/
|
|
interface NullishComparison {
|
|
ref: ESTree.Node;
|
|
nullish: 'null' | 'undefined';
|
|
}
|
|
|
|
/**
|
|
* If `node` compares a simple reference with `null` or `undefined` using one
|
|
* of the given operators, return the reference and which nullish value it is
|
|
* compared against; otherwise return null.
|
|
*/
|
|
function getNullishComparison(
|
|
node: ESTree.Node,
|
|
operators: Set<string>,
|
|
): NullishComparison | null {
|
|
if (node.type !== 'BinaryExpression' || !operators.has(node.operator)) {
|
|
return null;
|
|
}
|
|
for (const [side, ref] of [
|
|
[node.right, node.left],
|
|
[node.left, node.right],
|
|
]) {
|
|
if (side.type === 'Literal' && side.value === null) {
|
|
return { ref, nullish: 'null' };
|
|
}
|
|
if (side.type === 'Identifier' && side.name === 'undefined') {
|
|
return { ref, nullish: 'undefined' };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* `typeof` result literals that map onto an `@sindresorhus/is` helper with
|
|
* behaviour identical to the `typeof` comparison. Types are deliberately
|
|
* excluded when the helper's semantics diverge from a plain `typeof` check:
|
|
*
|
|
* - `'number'` — `isNumber()` also excludes `NaN`.
|
|
* - `'object'` — `isObject()` excludes `null` and includes functions.
|
|
*/
|
|
const TYPEOF_HELPERS: Record<string, string> = {
|
|
string: 'isString',
|
|
boolean: 'isBoolean',
|
|
function: 'isFunction',
|
|
symbol: 'isSymbol',
|
|
bigint: 'isBigint',
|
|
undefined: 'isUndefined',
|
|
};
|
|
|
|
/**
|
|
* If `node` is `typeof <expr> ==/===/!=/!== '<literalValue>'` (either operand
|
|
* order) for a `<literalValue>` with a matching helper in `TYPEOF_HELPERS`,
|
|
* return that literal value; otherwise return null.
|
|
*/
|
|
function getTypeofHelperLiteral(node: ESTree.BinaryExpression): string | null {
|
|
if (
|
|
!EQUALITY_OPERATORS.has(node.operator) &&
|
|
!INEQUALITY_OPERATORS.has(node.operator)
|
|
) {
|
|
return null;
|
|
}
|
|
for (const [a, b] of [
|
|
[node.left, node.right],
|
|
[node.right, node.left],
|
|
]) {
|
|
if (
|
|
a.type === 'UnaryExpression' &&
|
|
a.operator === 'typeof' &&
|
|
b.type === 'Literal' &&
|
|
typeof b.value === 'string' &&
|
|
b.value in TYPEOF_HELPERS
|
|
) {
|
|
return b.value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export default defineRule({
|
|
meta: {
|
|
type: 'suggestion',
|
|
messages: {
|
|
preferIsTruthy:
|
|
'Use `.filter(isTruthy)` with `isTruthy` from `@sindresorhus/is` instead of `.filter(Boolean)` for a properly typed result.',
|
|
preferIsHelperForTypeof:
|
|
"Use `{{helper}}()` from `@sindresorhus/is` instead of comparing `typeof` against '{{literal}}'.",
|
|
preferIsNullOrUndefined:
|
|
'Use `isNullOrUndefined()` from `@sindresorhus/is` instead of comparing against both `null` and `undefined`.',
|
|
preferNotIsNullOrUndefined:
|
|
'Use `!isNullOrUndefined()` from `@sindresorhus/is` instead of comparing against both `null` and `undefined`.',
|
|
},
|
|
},
|
|
createOnce(context) {
|
|
return {
|
|
CallExpression(node) {
|
|
// `.filter(Boolean)`
|
|
if (
|
|
node.callee.type === 'MemberExpression' &&
|
|
!node.callee.computed &&
|
|
node.callee.property.type === 'Identifier' &&
|
|
node.callee.property.name === 'filter' &&
|
|
node.arguments.length === 1 &&
|
|
node.arguments[0].type === 'Identifier' &&
|
|
node.arguments[0].name === 'Boolean'
|
|
) {
|
|
context.report({ node, messageId: 'preferIsTruthy' });
|
|
}
|
|
},
|
|
BinaryExpression(node) {
|
|
// `typeof x === 'string'` / `typeof x !== 'string'` / etc.
|
|
const literal = getTypeofHelperLiteral(node);
|
|
if (literal) {
|
|
context.report({
|
|
node,
|
|
messageId: 'preferIsHelperForTypeof',
|
|
data: { helper: TYPEOF_HELPERS[literal], literal },
|
|
});
|
|
}
|
|
},
|
|
LogicalExpression(node) {
|
|
// `x === null || x === undefined` / `x !== null && x !== undefined`
|
|
let operators: Set<string>;
|
|
if (node.operator === '||') {
|
|
operators = EQUALITY_OPERATORS;
|
|
} else if (node.operator === '&&') {
|
|
operators = INEQUALITY_OPERATORS;
|
|
} else {
|
|
return;
|
|
}
|
|
const left = getNullishComparison(node.left, operators);
|
|
const right = getNullishComparison(node.right, operators);
|
|
if (
|
|
left &&
|
|
right &&
|
|
left.nullish !== right.nullish &&
|
|
isSameRef(left.ref, right.ref)
|
|
) {
|
|
context.report({
|
|
node,
|
|
messageId:
|
|
node.operator === '||'
|
|
? 'preferIsNullOrUndefined'
|
|
: 'preferNotIsNullOrUndefined',
|
|
});
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|