eslint-rule-forge 0.1.0 → 0.3.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 +49 -9
- package/dist/index.d.mts +2 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -1
- package/dist/negation.d.mts +102 -0
- package/dist/negation.d.mts.map +1 -0
- package/dist/negation.mjs +215 -0
- package/dist/negation.mjs.map +1 -0
- package/dist/rules/index.d.mts +6 -0
- package/dist/rules/index.d.mts.map +1 -0
- package/dist/rules/index.mjs +6 -0
- package/dist/rules/index.mjs.map +1 -0
- package/dist/rules/no-side-effect-import.d.mts +18 -0
- package/dist/rules/no-side-effect-import.d.mts.map +1 -0
- package/dist/rules/no-side-effect-import.mjs +45 -0
- package/dist/rules/no-side-effect-import.mjs.map +1 -0
- package/dist/rules/prefer-array-is-array.d.mts +13 -0
- package/dist/rules/prefer-array-is-array.d.mts.map +1 -0
- package/dist/rules/prefer-array-is-array.mjs +81 -0
- package/dist/rules/prefer-array-is-array.mjs.map +1 -0
- package/dist/rules/prefer-is-non-null-object.d.mts +11 -0
- package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -0
- package/dist/rules/prefer-is-non-null-object.mjs +192 -0
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -0
- package/dist/rules/prefer-is-record-and-has-key.d.mts +11 -0
- package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -0
- package/dist/rules/prefer-is-record-and-has-key.mjs +226 -0
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -0
- package/dist/rules/prefer-safe-parse.d.mts +31 -0
- package/dist/rules/prefer-safe-parse.d.mts.map +1 -0
- package/dist/rules/prefer-safe-parse.mjs +139 -0
- package/dist/rules/prefer-safe-parse.mjs.map +1 -0
- package/dist/type-wrappers.d.mts +17 -1
- package/dist/type-wrappers.d.mts.map +1 -1
- package/dist/type-wrappers.mjs +35 -1
- package/dist/type-wrappers.mjs.map +1 -1
- package/package.json +10 -10
- package/src/index.mts +2 -0
- package/src/negation.mts +356 -0
- package/src/rules/index.mts +5 -0
- package/src/rules/no-side-effect-import.mts +68 -0
- package/src/rules/prefer-array-is-array.mts +119 -0
- package/src/rules/prefer-is-non-null-object.mts +276 -0
- package/src/rules/prefer-is-record-and-has-key.mts +321 -0
- package/src/rules/prefer-safe-parse.mts +252 -0
- package/src/type-wrappers.mts +55 -1
package/dist/type-wrappers.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type TSESTree } from '@typescript-eslint/utils';
|
|
1
|
+
import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
|
|
2
2
|
import type { DeepReadonly, RelaxedExtract } from 'ts-type-forge';
|
|
3
|
+
import type * as ts from 'typescript';
|
|
3
4
|
/**
|
|
4
5
|
* The four expressions that change a value's type and nothing else: `x as T`,
|
|
5
6
|
* `x!`, `x satisfies T` and `<T>x`.
|
|
@@ -41,4 +42,19 @@ export declare const skipTypeWrappers: <N extends DeepReadonly<TSESTree.Node>>(n
|
|
|
41
42
|
* layer too.
|
|
42
43
|
*/
|
|
43
44
|
export declare const typeWrapperLayers: <N extends DeepReadonly<TSESTree.Node>>(node: N) => readonly (N | WrappedExpression<N>)[];
|
|
45
|
+
/**
|
|
46
|
+
* Whether `node`, read through its type wrappers, is the identifier `name`:
|
|
47
|
+
* `Number`, `(Number as NumberConstructor)` and `Number!` are the same object.
|
|
48
|
+
*/
|
|
49
|
+
export declare const isIdentifierNamed: (node: DeepReadonly<TSESTree.Node>, name: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Whether `holds` is true of the type of every layer of type wrappers around
|
|
52
|
+
* `node` (see {@link typeWrapperLayers}), outermost to innermost; `false`
|
|
53
|
+
* without type information. A widening cast (`s as string | number`) is the
|
|
54
|
+
* author saying the value may be more than it is, and is respected; a
|
|
55
|
+
* narrowing one (`s!`, `v as unknown as string`) cannot vouch for what the
|
|
56
|
+
* value's own type does not, and the fix, which keeps the wrapper, has to
|
|
57
|
+
* type-check at every layer too.
|
|
58
|
+
*/
|
|
59
|
+
export declare const typeHoldsAtEveryLayer: (sourceCode: TSESLint.SourceCode, node: TSESTree.Node, holds: (type: ts.Type, typeChecker: ts.TypeChecker) => boolean) => boolean;
|
|
44
60
|
//# sourceMappingURL=type-wrappers.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"type-wrappers.d.mts","sourceRoot":"","sources":["../src/type-wrappers.mts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"type-wrappers.d.mts","sourceRoot":"","sources":["../src/type-wrappers.mts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,QAAQ,EACd,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAClE,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAC;AAEtC;;;GAGG;AACH,MAAM,MAAM,WAAW,GACnB,QAAQ,CAAC,cAAc,GACvB,QAAQ,CAAC,mBAAmB,GAC5B,QAAQ,CAAC,qBAAqB,GAC9B,QAAQ,CAAC,eAAe,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAAI,cAAc,CAC/C,CAAC,EACD,YAAY,CAAC,WAAW,CAAC,GAAG,WAAW,CACxC,CAAC,YAAY,CAAC,CAAC;AAEhB;;;GAGG;AACH,eAAO,MAAM,aAAa,SAClB,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,KAChC,IAAI,IAAI,YAAY,CAAC,WAAW,CAAC,GAAG,WACT,CAAC;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,SAAS,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,QAC9D,CAAC,KACN,CAAC,GAAG,iBAAiB,CAAC,CAAC,CACsC,CAAC;AAEjE;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,SAAS,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,QAC/D,CAAC,KACN,SAAS,CAAC,CAAC,GAAG,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAGf,CAAC;AAExB;;;GAGG;AACH,eAAO,MAAM,iBAAiB,SACtB,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,QAC3B,MAAM,KACX,OAMF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,eAEpB,QAAQ,CAAC,UAAU,QAEzB,QAAQ,CAAC,IAAI,SAEZ,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC,WAAW,KAAK,OAAO,KAC7D,OAgBF,CAAC"}
|
package/dist/type-wrappers.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
@@ -30,6 +30,40 @@ export const skipTypeWrappers = (node) => isTypeWrapper(node) ? skipTypeWrappers
|
|
|
30
30
|
export const typeWrapperLayers = (node) => isTypeWrapper(node)
|
|
31
31
|
? [node, ...typeWrapperLayers(node.expression)]
|
|
32
32
|
: [node];
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export const isIdentifierNamed = (node, name) => {
|
|
38
|
+
const unwrapped = skipTypeWrappers(node);
|
|
39
|
+
return (unwrapped.type === AST_NODE_TYPES.Identifier && unwrapped.name === name);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export const typeHoldsAtEveryLayer = (
|
|
51
|
+
|
|
52
|
+
sourceCode,
|
|
53
|
+
|
|
54
|
+
node,
|
|
55
|
+
|
|
56
|
+
holds) => {
|
|
57
|
+
const services = sourceCode.parserServices;
|
|
58
|
+
const checker = services?.program?.getTypeChecker();
|
|
59
|
+
if (checker === undefined) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return typeWrapperLayers(node).every((layer) => {
|
|
63
|
+
const tsNode = services?.esTreeNodeToTSNodeMap?.get(layer);
|
|
64
|
+
return (tsNode !== undefined && holds(checker.getTypeAtLocation(tsNode), checker));
|
|
65
|
+
});
|
|
66
|
+
};
|
|
33
67
|
const TYPE_WRAPPERS = new Set([
|
|
34
68
|
AST_NODE_TYPES.TSAsExpression,
|
|
35
69
|
AST_NODE_TYPES.TSNonNullExpression,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"type-wrappers.mjs","sourceRoot":"","sources":["../src/type-wrappers.mts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"type-wrappers.mjs","sourceRoot":"","sources":["../src/type-wrappers.mts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,GAGf,MAAM,0BAA0B,CAAC;AAyBlC;;;GAGG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,IAAiC,EACgB,EAAE,CACnD,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAE/B;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,IAAO,EACmB,EAAE,CAC5B,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAEjE;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAC/B,IAAO,EACgC,EAAE,CACzC,aAAa,CAAC,IAAI,CAAC;IACjB,CAAC,CAAE,CAAC,IAAI,EAAE,GAAG,iBAAiB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAW;IAC1D,CAAC,CAAE,CAAC,IAAI,CAAW,CAAC;AAExB;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAC/B,IAAiC,EACjC,IAAY,EACH,EAAE;IACX,MAAM,SAAS,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAEzC,OAAO,CACL,SAAS,CAAC,IAAI,KAAK,cAAc,CAAC,UAAU,IAAI,SAAS,CAAC,IAAI,KAAK,IAAI,CACxE,CAAC;AACJ,CAAC,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;AACnC,8EAA8E;AAC9E,UAA+B;AAC/B,8EAA8E;AAC9E,IAAmB;AACnB,8EAA8E;AAC9E,KAA8D,EACrD,EAAE;IACX,MAAM,QAAQ,GAAG,UAAU,CAAC,cAAc,CAAC;IAE3C,MAAM,OAAO,GAAG,QAAQ,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC;IAEpD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAC;IACf,CAAC;IAED,OAAO,iBAAiB,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;QAC7C,MAAM,MAAM,GAAG,QAAQ,EAAE,qBAAqB,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;QAE3D,OAAO,CACL,MAAM,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,CAAC,iBAAiB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAC1E,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,MAAM,aAAa,GAAgC,IAAI,GAAG,CAAC;IACzD,cAAc,CAAC,cAAc;IAC7B,cAAc,CAAC,mBAAmB;IAClC,cAAc,CAAC,qBAAqB;IACpC,cAAc,CAAC,eAAe;CAC/B,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-rule-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Helpers for writing typescript-eslint rules that see through type wrappers and parentheses, and resolve what a file imports.",
|
|
6
6
|
"keywords": [
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"@typescript-eslint/rule-tester": "8.71.1",
|
|
42
42
|
"dedent": "1.7.2",
|
|
43
43
|
"eslint": "10.12.0",
|
|
44
|
-
"eslint-config-typed": "5.
|
|
45
|
-
"eslint-plugin-ts-fortress": "1.3.
|
|
46
|
-
"eslint-plugin-ts-type-forge": "0.8.
|
|
44
|
+
"eslint-config-typed": "5.19.1",
|
|
45
|
+
"eslint-plugin-ts-fortress": "1.3.6",
|
|
46
|
+
"eslint-plugin-ts-type-forge": "0.8.11",
|
|
47
47
|
"prettier": "3.9.9",
|
|
48
48
|
"prettier-plugin-organize-imports": "4.3.0",
|
|
49
49
|
"prettier-plugin-packagejson": "3.0.2",
|
|
50
|
-
"ts-data-forge": "14.
|
|
50
|
+
"ts-data-forge": "14.9.0",
|
|
51
51
|
"ts-repo-utils": "10.7.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
@@ -65,15 +65,15 @@
|
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/build.mts",
|
|
67
67
|
"check:ext": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-ext.mts",
|
|
68
|
-
"check:lint": "oxlint . && eslint .",
|
|
69
|
-
"check:test": "pnpm run z:vitest run",
|
|
70
|
-
"check:test:cov": "pnpm run z:vitest run --coverage",
|
|
71
|
-
"check:types": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
68
|
+
"check:lint": "node ../../tools/scripts/cmd/assert-deps-built.mts && oxlint . && eslint .",
|
|
69
|
+
"check:test": "node ../../tools/scripts/cmd/assert-deps-built.mts && pnpm run z:vitest run",
|
|
70
|
+
"check:test:cov": "node ../../tools/scripts/cmd/assert-deps-built.mts && pnpm run z:vitest run --coverage",
|
|
71
|
+
"check:types": "node ../../tools/scripts/cmd/assert-deps-built.mts && node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
72
72
|
"doc": "pnpm run gen:readme",
|
|
73
73
|
"fix:fmt": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-uncommitted.mts --cwd .",
|
|
74
74
|
"fix:fmt:diff": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-diff-from.mts origin/main --cwd .",
|
|
75
75
|
"fix:fmt:full": "pnpm run z:prettier .",
|
|
76
|
-
"fix:lint": "oxlint . --fix && eslint . --fix",
|
|
76
|
+
"fix:lint": "node ../../tools/scripts/cmd/assert-deps-built.mts && oxlint . --fix && eslint . --fix",
|
|
77
77
|
"fmt": "pnpm run fix:fmt",
|
|
78
78
|
"gen": "run-s gen:index gen:readme",
|
|
79
79
|
"gen:index": "run-s gen:index:src fix:fmt",
|
package/src/index.mts
CHANGED
package/src/negation.mts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type ParserServicesWithTypeInformation,
|
|
4
|
+
type TSESLint,
|
|
5
|
+
type TSESTree,
|
|
6
|
+
} from '@typescript-eslint/utils';
|
|
7
|
+
import type { DeepReadonly, StrictPick } from 'ts-type-forge';
|
|
8
|
+
import * as ts from 'typescript';
|
|
9
|
+
import { typeWrapperLayers } from './type-wrappers.mjs';
|
|
10
|
+
|
|
11
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
12
|
+
|
|
13
|
+
/** Each comparison operator, and the one that is true exactly when it is not. */
|
|
14
|
+
export const INVERTED_COMPARISON_OPERATOR = {
|
|
15
|
+
'===': '!==',
|
|
16
|
+
'!==': '===',
|
|
17
|
+
'==': '!=',
|
|
18
|
+
'!=': '==',
|
|
19
|
+
'<': '>=',
|
|
20
|
+
'>=': '<',
|
|
21
|
+
'>': '<=',
|
|
22
|
+
'<=': '>',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type ComparisonOperator = keyof typeof INVERTED_COMPARISON_OPERATOR;
|
|
26
|
+
|
|
27
|
+
export type ComparisonExpression = DeepReadonly<TSESTree.BinaryExpression> &
|
|
28
|
+
Readonly<{ operator: ComparisonOperator }>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The type of a node at each of its type-wrapper layers, outermost first (see
|
|
32
|
+
* {@link typeWrapperLayers}).
|
|
33
|
+
*/
|
|
34
|
+
export type GetLayerTypes = (
|
|
35
|
+
node: DeepReadonly<TSESTree.Node>,
|
|
36
|
+
) => readonly ts.Type[];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds a {@link GetLayerTypes}: `x as A` gives the types of `x as A` and of
|
|
40
|
+
* `x`.
|
|
41
|
+
*
|
|
42
|
+
* A conclusion a rule draws from a type is drawn only when it holds for every
|
|
43
|
+
* one of them. A widening `as` (`b as boolean | number`) is then the author
|
|
44
|
+
* saying the value may be something else, and is respected, while a narrowing
|
|
45
|
+
* one (`n as unknown as boolean`) leaves the value's own type to decide.
|
|
46
|
+
*/
|
|
47
|
+
export const createGetLayerTypes =
|
|
48
|
+
(
|
|
49
|
+
services: StrictPick<
|
|
50
|
+
ParserServicesWithTypeInformation,
|
|
51
|
+
'esTreeNodeToTSNodeMap' | 'program'
|
|
52
|
+
>,
|
|
53
|
+
): GetLayerTypes =>
|
|
54
|
+
(node) => {
|
|
55
|
+
const checker = services.program.getTypeChecker();
|
|
56
|
+
|
|
57
|
+
return typeWrapperLayers(node).map((layer) =>
|
|
58
|
+
checker.getTypeAtLocation(
|
|
59
|
+
services.esTreeNodeToTSNodeMap.get(asNode(layer)),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const isComparison = (
|
|
65
|
+
node: DeepReadonly<TSESTree.Node>,
|
|
66
|
+
): node is ComparisonExpression =>
|
|
67
|
+
node.type === AST_NODE_TYPES.BinaryExpression &&
|
|
68
|
+
Object.hasOwn(INVERTED_COMPARISON_OPERATOR, node.operator);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether inverting the operator gives exactly the negation.
|
|
72
|
+
*
|
|
73
|
+
* An equality always does. A relational comparison does unless an operand
|
|
74
|
+
* converts to `NaN`: every relational comparison with `NaN` is `false`, so
|
|
75
|
+
* `!(a < b)` is `true` there while `a >= b` is `false`. It cannot when both
|
|
76
|
+
* sides are strings (compared as strings), or both are numeric values that are
|
|
77
|
+
* never `NaN` — number literals, `bigint`, booleans, and number brands that
|
|
78
|
+
* declare `NaNValue: false` (`ValidNumber`, `Int`, `FiniteNumber`, …).
|
|
79
|
+
*
|
|
80
|
+
* `getLayerTypes` gives an operand's type at each of its type-wrapper layers,
|
|
81
|
+
* and every one of them has to say so: `(v as number) < 1` may be `NaN` even
|
|
82
|
+
* when `v` is a `ValidNumber`, and `(n as ValidNumber) < 1` still may when `n`
|
|
83
|
+
* is a `number`.
|
|
84
|
+
*/
|
|
85
|
+
export const isExactlyInvertible = (
|
|
86
|
+
node: ComparisonExpression,
|
|
87
|
+
getLayerTypes: GetLayerTypes,
|
|
88
|
+
checker: ts.TypeChecker,
|
|
89
|
+
): boolean => {
|
|
90
|
+
if (!RELATIONAL_OPERATORS.has(node.operator)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const sides = [
|
|
95
|
+
...getLayerTypes(node.left),
|
|
96
|
+
...getLayerTypes(node.right),
|
|
97
|
+
].flatMap(constituentsOf);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
sides.every((type) => (type.flags & ts.TypeFlags.StringLike) !== 0) ||
|
|
101
|
+
sides.every((type) => isNeverNaN(type, checker))
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The source of `node` with its operator inverted, and everything else —
|
|
107
|
+
* operands, comments, line breaks — as written.
|
|
108
|
+
*/
|
|
109
|
+
export const invertedComparisonText = (
|
|
110
|
+
node: ComparisonExpression,
|
|
111
|
+
sourceCode: DeepReadonly<TSESLint.SourceCode>,
|
|
112
|
+
): string => {
|
|
113
|
+
const text = sourceCode.getText(asNode(node));
|
|
114
|
+
|
|
115
|
+
const operator = sourceCode.getFirstTokenBetween(
|
|
116
|
+
asNode(node.left),
|
|
117
|
+
asNode(node.right),
|
|
118
|
+
(token) => token.value === node.operator,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (operator === null) {
|
|
122
|
+
return `${sourceCode.getText(asNode(node.left))} ${INVERTED_COMPARISON_OPERATOR[node.operator]} ${sourceCode.getText(asNode(node.right))}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const [start] = node.range;
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
text.slice(0, operator.range[0] - start),
|
|
129
|
+
INVERTED_COMPARISON_OPERATOR[node.operator],
|
|
130
|
+
text.slice(operator.range[1] - start),
|
|
131
|
+
].join('');
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** What {@link negationOf} needs to know about the code it negates. */
|
|
135
|
+
export type NegationContext = Readonly<{
|
|
136
|
+
sourceCode: DeepReadonly<TSESLint.SourceCode>;
|
|
137
|
+
checker: ts.TypeChecker;
|
|
138
|
+
getLayerTypes: GetLayerTypes;
|
|
139
|
+
/**
|
|
140
|
+
* Whether `node`'s value is a boolean, so that the negation of `!node` may
|
|
141
|
+
* be `node` itself. Where only the truthiness of the negation is read — the
|
|
142
|
+
* test of an `if` — any value will do, and this may answer `true` for every
|
|
143
|
+
* node.
|
|
144
|
+
*/
|
|
145
|
+
isBoolean: (node: DeepReadonly<TSESTree.Expression>) => boolean;
|
|
146
|
+
}>;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The negation of `node`, written the short way, as the source text of an
|
|
150
|
+
* operand of `operandOf` (parenthesized if need be), or of a whole expression
|
|
151
|
+
* when `operandOf` is `undefined`:
|
|
152
|
+
*
|
|
153
|
+
* | `node` | negation | when |
|
|
154
|
+
* | :----------------- | :---------------- | :------------------------------------ |
|
|
155
|
+
* | `!a` | `a` | `a` is a boolean (`context.isBoolean`) |
|
|
156
|
+
* | `a === b` | `a !== b` | always |
|
|
157
|
+
* | `a < b` | `a >= b` | no operand can be `NaN` |
|
|
158
|
+
* | `a && b`, `a \|\| b` | `!a \|\| !b`, `!a && !b` | always, each side negated the same way |
|
|
159
|
+
* | anything else | `!a`, `!(a)` | always |
|
|
160
|
+
*
|
|
161
|
+
* `undefined` when the first two rows' condition does not hold: then the only
|
|
162
|
+
* exact negation is `!!a` or `!(a < b)`, and a caller decides whether to
|
|
163
|
+
* write it (with {@link prefixedNegationOf}) or to give up. Inside `&&` and
|
|
164
|
+
* `||`, a side with no short negation is written with `!`.
|
|
165
|
+
*
|
|
166
|
+
* The node is taken as written: a comparison under a type wrapper is not
|
|
167
|
+
* looked into, since inverting it would drop the wrapper.
|
|
168
|
+
*/
|
|
169
|
+
export const negationOf = (
|
|
170
|
+
node: DeepReadonly<TSESTree.Expression>,
|
|
171
|
+
context: NegationContext,
|
|
172
|
+
operandOf: '&&' | '||' | undefined,
|
|
173
|
+
): string | undefined => {
|
|
174
|
+
const negation = shortNegationOf(node, context);
|
|
175
|
+
|
|
176
|
+
return negation === undefined ? undefined : asOperand(negation, operandOf);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `!node`, with `node` parenthesized unless `!` already binds to all of it:
|
|
181
|
+
* `!a`, `!f(x)`, `!(a === b)`.
|
|
182
|
+
*/
|
|
183
|
+
export const prefixedNegationOf = (
|
|
184
|
+
node: DeepReadonly<TSESTree.Expression>,
|
|
185
|
+
sourceCode: DeepReadonly<TSESLint.SourceCode>,
|
|
186
|
+
): string => {
|
|
187
|
+
const text = sourceCode.getText(asNode(node));
|
|
188
|
+
|
|
189
|
+
return BINDS_AS_TIGHT_AS_UNARY.has(node.type) ? `!${text}` : `!(${text})`;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether `node`, written as an operand of `operator`, needs parentheses to
|
|
194
|
+
* stay one operand: `a || b` under `&&`, `a ?? b` under either, and anything
|
|
195
|
+
* binding looser than both (`a ? b : c`, `a = b`, …).
|
|
196
|
+
*/
|
|
197
|
+
export const needsParenthesesAsOperandOf = (
|
|
198
|
+
node: DeepReadonly<TSESTree.Node>,
|
|
199
|
+
operator: '&&' | '||',
|
|
200
|
+
): boolean => isLooserThan(precedenceOf(node), operator);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How tightly an expression binds, as far as putting it under `&&` or `||` is
|
|
204
|
+
* concerned.
|
|
205
|
+
*/
|
|
206
|
+
type Precedence = '&&' | '||' | 'looser' | 'tighter';
|
|
207
|
+
|
|
208
|
+
type Negation = Readonly<{
|
|
209
|
+
text: string;
|
|
210
|
+
precedence: Precedence;
|
|
211
|
+
}>;
|
|
212
|
+
|
|
213
|
+
const shortNegationOf = (
|
|
214
|
+
node: DeepReadonly<TSESTree.Expression>,
|
|
215
|
+
context: NegationContext,
|
|
216
|
+
): Negation | undefined => {
|
|
217
|
+
if (node.type === AST_NODE_TYPES.UnaryExpression && node.operator === '!') {
|
|
218
|
+
return context.isBoolean(node.argument)
|
|
219
|
+
? {
|
|
220
|
+
text: context.sourceCode.getText(asNode(node.argument)),
|
|
221
|
+
precedence: precedenceOf(node.argument),
|
|
222
|
+
}
|
|
223
|
+
: undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (isComparison(node)) {
|
|
227
|
+
return isExactlyInvertible(node, context.getLayerTypes, context.checker)
|
|
228
|
+
? {
|
|
229
|
+
text: invertedComparisonText(node, context.sourceCode),
|
|
230
|
+
precedence: 'tighter',
|
|
231
|
+
}
|
|
232
|
+
: undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
node.type === AST_NODE_TYPES.LogicalExpression &&
|
|
237
|
+
node.operator !== '??'
|
|
238
|
+
) {
|
|
239
|
+
const operator = node.operator === '&&' ? '||' : '&&';
|
|
240
|
+
|
|
241
|
+
const side = (operand: DeepReadonly<TSESTree.Expression>): string =>
|
|
242
|
+
asOperand(
|
|
243
|
+
shortNegationOf(operand, context) ?? {
|
|
244
|
+
text: prefixedNegationOf(operand, context.sourceCode),
|
|
245
|
+
precedence: 'tighter',
|
|
246
|
+
},
|
|
247
|
+
operator,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
text: `${side(node.left)} ${operator} ${side(node.right)}`,
|
|
252
|
+
precedence: operator,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
text: prefixedNegationOf(node, context.sourceCode),
|
|
258
|
+
precedence: 'tighter',
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const asOperand = (
|
|
263
|
+
negation: Negation,
|
|
264
|
+
operator: '&&' | '||' | undefined,
|
|
265
|
+
): string =>
|
|
266
|
+
operator !== undefined && isLooserThan(negation.precedence, operator)
|
|
267
|
+
? (`(${negation.text})` as const)
|
|
268
|
+
: negation.text;
|
|
269
|
+
|
|
270
|
+
const isLooserThan = (precedence: Precedence, operator: '&&' | '||'): boolean =>
|
|
271
|
+
precedence === 'looser' || (operator === '&&' && precedence === '||');
|
|
272
|
+
|
|
273
|
+
const precedenceOf = (node: DeepReadonly<TSESTree.Node>): Precedence => {
|
|
274
|
+
if (node.type === AST_NODE_TYPES.LogicalExpression) {
|
|
275
|
+
// `??` cannot be mixed with `&&` or `||` without parentheses.
|
|
276
|
+
return node.operator === '??' ? 'looser' : node.operator;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return LOOSER_THAN_LOGICAL.has(node.type) ? 'looser' : 'tighter';
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const LOOSER_THAN_LOGICAL: ReadonlySet<AST_NODE_TYPES> = new Set([
|
|
283
|
+
AST_NODE_TYPES.ArrowFunctionExpression,
|
|
284
|
+
AST_NODE_TYPES.AssignmentExpression,
|
|
285
|
+
AST_NODE_TYPES.ConditionalExpression,
|
|
286
|
+
AST_NODE_TYPES.SequenceExpression,
|
|
287
|
+
AST_NODE_TYPES.YieldExpression,
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
/** Expressions `!` can be put in front of without parentheses. */
|
|
291
|
+
const BINDS_AS_TIGHT_AS_UNARY: ReadonlySet<AST_NODE_TYPES> = new Set([
|
|
292
|
+
AST_NODE_TYPES.ArrayExpression,
|
|
293
|
+
AST_NODE_TYPES.AwaitExpression,
|
|
294
|
+
AST_NODE_TYPES.CallExpression,
|
|
295
|
+
AST_NODE_TYPES.ChainExpression,
|
|
296
|
+
AST_NODE_TYPES.Identifier,
|
|
297
|
+
AST_NODE_TYPES.Literal,
|
|
298
|
+
AST_NODE_TYPES.MemberExpression,
|
|
299
|
+
AST_NODE_TYPES.MetaProperty,
|
|
300
|
+
AST_NODE_TYPES.NewExpression,
|
|
301
|
+
AST_NODE_TYPES.ObjectExpression,
|
|
302
|
+
AST_NODE_TYPES.TaggedTemplateExpression,
|
|
303
|
+
AST_NODE_TYPES.TemplateLiteral,
|
|
304
|
+
AST_NODE_TYPES.ThisExpression,
|
|
305
|
+
AST_NODE_TYPES.TSNonNullExpression,
|
|
306
|
+
AST_NODE_TYPES.UnaryExpression,
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
const RELATIONAL_OPERATORS: ReadonlySet<ComparisonOperator> =
|
|
310
|
+
new Set<ComparisonOperator>(['<', '<=', '>', '>=']);
|
|
311
|
+
|
|
312
|
+
const constituentsOf = (type: ts.Type): readonly ts.Type[] =>
|
|
313
|
+
type.isUnion() ? type.types : ([type] as const);
|
|
314
|
+
|
|
315
|
+
const isNeverNaN = (type: ts.Type, checker: ts.TypeChecker): boolean => {
|
|
316
|
+
// Read `ts.TypeFlags` here, not at module scope: every importer of this
|
|
317
|
+
// package's index evaluates the module, and a `typescript` without the JS
|
|
318
|
+
// API (7.x) would fail the import even for rules that never get here.
|
|
319
|
+
const neverNaNFlags =
|
|
320
|
+
ts.TypeFlags.NumberLiteral |
|
|
321
|
+
ts.TypeFlags.BigIntLike |
|
|
322
|
+
ts.TypeFlags.BooleanLike |
|
|
323
|
+
ts.TypeFlags.Null;
|
|
324
|
+
|
|
325
|
+
if ((type.flags & neverNaNFlags) !== 0) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!type.isIntersection()) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const brand = checker.getPropertyOfType(type, 'NaNValue');
|
|
334
|
+
|
|
335
|
+
if (brand === undefined) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const brandType = checker.getTypeOfSymbol(brand);
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
type.types.some(
|
|
343
|
+
(member) => (member.flags & ts.TypeFlags.NumberLike) !== 0,
|
|
344
|
+
) &&
|
|
345
|
+
(brandType.flags & ts.TypeFlags.BooleanLiteral) !== 0 &&
|
|
346
|
+
checker.typeToString(brandType) === 'false'
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The parser's node, which the source code and the token store take as
|
|
352
|
+
* mutable, for one that this package only reads.
|
|
353
|
+
*/
|
|
354
|
+
const asNode = <T extends TSESTree.Node>(node: DeepReadonly<T>): T =>
|
|
355
|
+
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
356
|
+
node as T;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a rule that removes `import '<moduleName>';`, an import that binds no
|
|
5
|
+
* name. The rule matches the bare specifier exactly, so a subpath such as
|
|
6
|
+
* `<moduleName>/global` and every other module are left alone.
|
|
7
|
+
*
|
|
8
|
+
* Why such an import is wrong depends on the module — one with
|
|
9
|
+
* `sideEffects: false` loads for nothing, one with no runtime entry point fails
|
|
10
|
+
* to resolve — so the caller says it, in `description` and `message`, and
|
|
11
|
+
* chooses `type` accordingly.
|
|
12
|
+
*/
|
|
13
|
+
export const createNoSideEffectImport = ({
|
|
14
|
+
moduleName,
|
|
15
|
+
type,
|
|
16
|
+
description,
|
|
17
|
+
message,
|
|
18
|
+
}: Readonly<{
|
|
19
|
+
moduleName: string;
|
|
20
|
+
type: TSESLint.RuleMetaData<string, unknown>['type'];
|
|
21
|
+
description: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}>): TSESLint.RuleModule<'removeSideEffectImport', readonly []> =>
|
|
24
|
+
({
|
|
25
|
+
meta: {
|
|
26
|
+
type,
|
|
27
|
+
docs: { description },
|
|
28
|
+
fixable: 'code',
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: { removeSideEffectImport: message },
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
create: (context) => ({
|
|
34
|
+
ImportDeclaration: (node) => {
|
|
35
|
+
if (node.source.value !== moduleName || node.specifiers.length > 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
context.report({
|
|
40
|
+
node,
|
|
41
|
+
messageId: 'removeSideEffectImport',
|
|
42
|
+
fix: (fixer) => removeStatement(fixer, context.sourceCode, node),
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
defaultOptions: [],
|
|
47
|
+
}) as const;
|
|
48
|
+
|
|
49
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes a statement along with the rest of its line, so that deleting it does
|
|
53
|
+
* not leave a blank line where it was.
|
|
54
|
+
*/
|
|
55
|
+
const removeStatement = (
|
|
56
|
+
fixer: TSESLint.RuleFixer,
|
|
57
|
+
sourceCode: TSESLint.SourceCode,
|
|
58
|
+
node: TSESTree.Node,
|
|
59
|
+
): TSESLint.RuleFix => {
|
|
60
|
+
const restOfLine = /^[^\S\n]*\n/u.exec(
|
|
61
|
+
sourceCode.getText().slice(node.range[1]),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return fixer.removeRange([
|
|
65
|
+
node.range[0],
|
|
66
|
+
node.range[1] + (restOfLine?.[0].length ?? 0),
|
|
67
|
+
]);
|
|
68
|
+
};
|