@tianjos/eslint-plugin-elegant 0.7.1 → 0.8.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 +106 -0
- package/dist/index.d.ts +4 -1
- package/dist/rules/no-instanceof.d.ts +5 -1
- package/dist/rules/no-instanceof.js +40 -3
- package/dist/rules/no-self-mutation.js +23 -22
- package/dist/utils/ancestors.d.ts +10 -0
- package/dist/utils/ancestors.js +22 -0
- package/dist/utils/locals.d.ts +7 -0
- package/dist/utils/locals.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -245,6 +245,44 @@ around repositories and framework hooks, so it stays off in `recommended`.
|
|
|
245
245
|
the object. Pairs with `no-type-assertion` to keep type-based branching out of
|
|
246
246
|
the codebase.
|
|
247
247
|
|
|
248
|
+
Two uses are allowed by default, because in both of them TypeScript leaves no
|
|
249
|
+
polymorphic alternative to reach for.
|
|
250
|
+
|
|
251
|
+
**A self-guard** — `other instanceof Money` inside `class Money`. Value
|
|
252
|
+
equality has to guard its own type before comparing fields, and there is no
|
|
253
|
+
polymorphic way to write that: the method is already on the object, so the
|
|
254
|
+
rule's own advice has nowhere left to go. Guarding against a *different* type
|
|
255
|
+
(`other instanceof Currency` inside `Money`) is discrimination and is still
|
|
256
|
+
reported. Off via `{ allowSelfGuard: false }`.
|
|
257
|
+
|
|
258
|
+
**A caught value** — a name a `catch` clause introduced. TypeScript types it
|
|
259
|
+
as `unknown`, so `instanceof` is the only narrowing the language offers; no
|
|
260
|
+
method on the value can stand in, because at that point the value has no known
|
|
261
|
+
methods. Resolved through the scope chain, so the narrowing still counts one
|
|
262
|
+
closure deeper. Off via `{ allowCaughtValues: false }`.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
// allowed
|
|
266
|
+
class Money {
|
|
267
|
+
equals(other?: unknown): boolean {
|
|
268
|
+
return other instanceof Money && this.amount === other.amount;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
try { charge(); } catch (error) {
|
|
272
|
+
if (error instanceof HttpException) { log(error.getStatus()); }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// still reported
|
|
276
|
+
if (shape instanceof Circle) { draw(); }
|
|
277
|
+
function handle(error: HttpException) { return error instanceof HttpException; }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
An error that arrives as a plain parameter rather than through `catch` — Nest's
|
|
281
|
+
`ExceptionFilter.catch(exception, host)`, an RxJS `catchError` callback — is
|
|
282
|
+
**not** covered, because a parameter's type is whatever the signature says and
|
|
283
|
+
the rule reads no type information. Narrow it once at the boundary, or disable
|
|
284
|
+
the rule for those files.
|
|
285
|
+
|
|
248
286
|
#### `no-static-members`
|
|
249
287
|
|
|
250
288
|
Static state and behavior cannot be injected, substituted, or mocked. Prefer
|
|
@@ -683,6 +721,74 @@ rules: {
|
|
|
683
721
|
}
|
|
684
722
|
```
|
|
685
723
|
|
|
724
|
+
### Adopting on an existing codebase
|
|
725
|
+
|
|
726
|
+
The `recommended` config is written for the code you wish you had. Turning it
|
|
727
|
+
on over code that already exists is a different exercise, and worth planning
|
|
728
|
+
with numbers rather than discovering at the first `eslint .`.
|
|
729
|
+
|
|
730
|
+
Measured over **1,261 production TypeScript files** across three NestJS
|
|
731
|
+
services — a DTO-heavy, string-logging, TypeORM-backed shape this preset was
|
|
732
|
+
built for:
|
|
733
|
+
|
|
734
|
+
| Rule | Severity | Reports | Per file | In test files |
|
|
735
|
+
| --- | --- | ---: | ---: | ---: |
|
|
736
|
+
| `no-comments-in-function-body` | `error` | 5,559 | 4.41 | 2,589 |
|
|
737
|
+
| `no-interpolated-log-message` | `error` | 1,941 | 1.54 | 0 |
|
|
738
|
+
| `no-null` | `error` | 1,367 | 1.08 | 896 |
|
|
739
|
+
| `no-type-assertion` | `error` | 524 | 0.42 | 935 |
|
|
740
|
+
| `max-method-lines` | `warn` | 467 | 0.37 | 1 |
|
|
741
|
+
| `max-params` | `warn` | 195 | 0.15 | 6 |
|
|
742
|
+
| `no-null-return` | `error` | 194 | 0.15 | 2 |
|
|
743
|
+
| `no-instanceof` | `error` | 179 | 0.14 | 0 |
|
|
744
|
+
| `no-generic-error` | `error` | 163 | 0.13 | 17 |
|
|
745
|
+
| `max-returns` | `warn` | 125 | 0.10 | 0 |
|
|
746
|
+
| `no-public-mutable-props` | `error` | 116 | 0.09 | 0 |
|
|
747
|
+
| `no-property-alias` | `error` | 111 | 0.09 | 1 |
|
|
748
|
+
| `no-logic-in-constructor` | `error` | 98 | 0.08 | 0 |
|
|
749
|
+
| `no-static-members` | `error` | 96 | 0.08 | 0 |
|
|
750
|
+
| `no-anonymous-param-type` | `error` | 91 | 0.07 | 27 |
|
|
751
|
+
| `no-self-mutation` | `error` | 66 | 0.05 | 0 |
|
|
752
|
+
| `max-class-dependencies` | `warn` | 64 | 0.05 | 0 |
|
|
753
|
+
| `max-class-fields` | `warn` | 57 | 0.05 | 0 |
|
|
754
|
+
| `no-property-destructuring` | `error` | 53 | 0.04 | 0 |
|
|
755
|
+
| `no-boolean-param` | `error` | 47 | 0.04 | 1 |
|
|
756
|
+
| `max-class-methods` | `warn` | 40 | 0.03 | 0 |
|
|
757
|
+
| `no-getters-setters` | `error` | 29 | 0.02 | 0 |
|
|
758
|
+
| `no-else-return` | `error` | 13 | 0.01 | 0 |
|
|
759
|
+
| `no-else-after-throw` | `error` | 8 | 0.01 | 0 |
|
|
760
|
+
| **Total** | | **11,603** | **9.20** | |
|
|
761
|
+
|
|
762
|
+
Four rules account for 81% of it, and they are the four whose principle has a
|
|
763
|
+
boundary this plugin cannot see. `no-comments-in-function-body` asks you to
|
|
764
|
+
rewrite a function, not edit a line. `no-interpolated-log-message` fires on
|
|
765
|
+
whatever logging convention the project already chose, so on a codebase that
|
|
766
|
+
logs with template strings it fires everywhere. `no-null` cannot tell a domain
|
|
767
|
+
value from the wire format of a database column. `no-type-assertion` counts
|
|
768
|
+
`x as unknown as T` twice, once per assertion, which is arguably correct.
|
|
769
|
+
|
|
770
|
+
None of that makes them wrong — it makes them rules you adopt on purpose
|
|
771
|
+
rather than inherit. **Enable the preset, then take the top of that table back
|
|
772
|
+
to `warn` or `off` and work down it.** The rules below `no-type-assertion` sum
|
|
773
|
+
to 1.75 per file, which is a starting point you can actually clear:
|
|
774
|
+
|
|
775
|
+
```js
|
|
776
|
+
rules: {
|
|
777
|
+
...elegant.configs.recommended.rules,
|
|
778
|
+
|
|
779
|
+
// The four that need a plan of their own. Re-enable one at a time.
|
|
780
|
+
'elegant/no-comments-in-function-body': 'off',
|
|
781
|
+
'elegant/no-interpolated-log-message': 'warn',
|
|
782
|
+
'elegant/no-null': 'warn',
|
|
783
|
+
'elegant/no-type-assertion': 'warn',
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Numbers from one corpus are indicative, not universal. Run
|
|
788
|
+
`npx eslint . --format json` on your own and sort by rule before deciding
|
|
789
|
+
anything — the shape of your code decides which of these rules is a signal and
|
|
790
|
+
which is a migration.
|
|
791
|
+
|
|
686
792
|
### Relaxing rules in test files
|
|
687
793
|
|
|
688
794
|
Tests routinely use flag arguments and larger fixtures. Add a second config
|
package/dist/index.d.ts
CHANGED
|
@@ -40,7 +40,10 @@ declare const rules: {
|
|
|
40
40
|
}], unknown, TSESLint.RuleListener> & {
|
|
41
41
|
name: string;
|
|
42
42
|
};
|
|
43
|
-
'no-instanceof': TSESLint.RuleModule<"noInstanceof", [
|
|
43
|
+
'no-instanceof': TSESLint.RuleModule<"noInstanceof", [{
|
|
44
|
+
allowSelfGuard: boolean;
|
|
45
|
+
allowCaughtValues: boolean;
|
|
46
|
+
}], unknown, TSESLint.RuleListener> & {
|
|
44
47
|
name: string;
|
|
45
48
|
};
|
|
46
49
|
'no-static-members': TSESLint.RuleModule<"staticMember", [{
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
type Options = [{
|
|
2
|
+
allowSelfGuard: boolean;
|
|
3
|
+
allowCaughtValues: boolean;
|
|
4
|
+
}];
|
|
5
|
+
declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"noInstanceof", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
2
6
|
name: string;
|
|
3
7
|
};
|
|
4
8
|
export default _default;
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
4
|
+
const ancestors_1 = require("../utils/ancestors");
|
|
3
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const locals_1 = require("../utils/locals");
|
|
7
|
+
/** The name of the class a node sits inside, if it sits inside a named one. */
|
|
8
|
+
const enclosingClass = (node) => {
|
|
9
|
+
const found = (0, ancestors_1.closestAncestor)(node, (candidate) => candidate.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
10
|
+
candidate.type === utils_1.AST_NODE_TYPES.ClassExpression);
|
|
11
|
+
return found?.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
12
|
+
found?.type === utils_1.AST_NODE_TYPES.ClassExpression
|
|
13
|
+
? found.id?.name
|
|
14
|
+
: undefined;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* `other instanceof Money` inside `class Money`. Value equality has to guard
|
|
18
|
+
* its own type before comparing fields, and a structurally typed language
|
|
19
|
+
* offers no polymorphic way to do it: the method is already on the object, so
|
|
20
|
+
* the rule's own advice has nowhere left to go.
|
|
21
|
+
*/
|
|
22
|
+
const isSelfGuard = (node) => node.right.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
23
|
+
node.right.name === enclosingClass(node);
|
|
4
24
|
exports.default = (0, createRule_1.createRule)({
|
|
5
25
|
name: 'no-instanceof',
|
|
6
26
|
meta: {
|
|
@@ -11,12 +31,29 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
11
31
|
messages: {
|
|
12
32
|
noInstanceof: 'Avoid `instanceof`. Replace type discrimination with a polymorphic method on the object.',
|
|
13
33
|
},
|
|
14
|
-
schema: [
|
|
34
|
+
schema: [
|
|
35
|
+
{
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
allowSelfGuard: { type: 'boolean' },
|
|
39
|
+
allowCaughtValues: { type: 'boolean' },
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
15
44
|
},
|
|
16
|
-
defaultOptions: [],
|
|
17
|
-
create(context) {
|
|
45
|
+
defaultOptions: [{ allowSelfGuard: true, allowCaughtValues: true }],
|
|
46
|
+
create(context, [{ allowSelfGuard, allowCaughtValues }]) {
|
|
18
47
|
return {
|
|
19
48
|
'BinaryExpression[operator="instanceof"]'(node) {
|
|
49
|
+
if (allowSelfGuard && isSelfGuard(node)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (allowCaughtValues &&
|
|
53
|
+
node.left.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
54
|
+
(0, locals_1.isCaughtBinding)(context.sourceCode.getScope(node), node.left.name)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
20
57
|
context.report({ node, messageId: 'noInstanceof' });
|
|
21
58
|
},
|
|
22
59
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const utils_1 = require("@typescript-eslint/utils");
|
|
4
|
+
const ancestors_1 = require("../utils/ancestors");
|
|
4
5
|
const createRule_1 = require("../utils/createRule");
|
|
5
6
|
/**
|
|
6
7
|
* Nest calls these after the container has built the instance, so they finish
|
|
@@ -24,36 +25,34 @@ const fieldOf = (target) => target.type === utils_1.AST_NODE_TYPES.MemberExpress
|
|
|
24
25
|
target.property.type === utils_1.AST_NODE_TYPES.Identifier
|
|
25
26
|
? target.property.name
|
|
26
27
|
: undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Whether the write sits inside a class body at all. `this` outside one is
|
|
30
|
+
* `module.exports`, `undefined`, or an object literal's own receiver — none of
|
|
31
|
+
* which is an object with holders to surprise, so the rule has nothing to say
|
|
32
|
+
* about them.
|
|
33
|
+
*/
|
|
34
|
+
const isInsideClass = (node) => (0, ancestors_1.closestAncestor)(node, (candidate) => candidate.type === utils_1.AST_NODE_TYPES.ClassBody ||
|
|
35
|
+
candidate.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
36
|
+
candidate.type === utils_1.AST_NODE_TYPES.ClassExpression) !== undefined;
|
|
27
37
|
/**
|
|
28
38
|
* Whether the write happens while the object is still being built. Only the
|
|
29
39
|
* constructor's own body counts: a callback the constructor schedules runs
|
|
30
40
|
* after construction has returned, so it mutates a finished object.
|
|
31
41
|
*/
|
|
32
42
|
const isDuringConstruction = (node) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return (current.parent?.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
39
|
-
current.parent.kind === 'constructor');
|
|
40
|
-
}
|
|
41
|
-
current = current.parent;
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
43
|
+
const fn = (0, ancestors_1.closestAncestor)(node, (candidate) => candidate.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
44
|
+
candidate.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
45
|
+
candidate.type === utils_1.AST_NODE_TYPES.FunctionExpression);
|
|
46
|
+
return (fn?.parent?.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
47
|
+
fn.parent.kind === 'constructor');
|
|
44
48
|
};
|
|
45
49
|
/** The method a write sits in, if it sits in one directly. */
|
|
46
50
|
const enclosingMethod = (node) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
: undefined;
|
|
53
|
-
}
|
|
54
|
-
current = current.parent;
|
|
55
|
-
}
|
|
56
|
-
return undefined;
|
|
51
|
+
const method = (0, ancestors_1.closestAncestor)(node, (candidate) => candidate.type === utils_1.AST_NODE_TYPES.MethodDefinition);
|
|
52
|
+
return method?.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
53
|
+
method.key.type === utils_1.AST_NODE_TYPES.Identifier
|
|
54
|
+
? method.key.name
|
|
55
|
+
: undefined;
|
|
57
56
|
};
|
|
58
57
|
exports.default = (0, createRule_1.createRule)({
|
|
59
58
|
name: 'no-self-mutation',
|
|
@@ -79,7 +78,9 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
79
78
|
create(context, [{ allowedMethods }]) {
|
|
80
79
|
const check = (node, target) => {
|
|
81
80
|
const name = fieldOf(target);
|
|
82
|
-
if (name === undefined ||
|
|
81
|
+
if (name === undefined ||
|
|
82
|
+
!isInsideClass(node) ||
|
|
83
|
+
isDuringConstruction(node)) {
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
const method = enclosingMethod(node);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* The innermost ancestor a predicate accepts, or `undefined` if the walk
|
|
4
|
+
* reaches the top without one.
|
|
5
|
+
*
|
|
6
|
+
* `Program.parent` is `null` rather than `undefined`, so a walk that only
|
|
7
|
+
* guards against `undefined` runs off the top of the tree and throws — which
|
|
8
|
+
* takes the whole lint run for that file down with it.
|
|
9
|
+
*/
|
|
10
|
+
export declare const closestAncestor: (node: TSESTree.Node, matches: (candidate: TSESTree.Node) => boolean) => TSESTree.Node | undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.closestAncestor = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The innermost ancestor a predicate accepts, or `undefined` if the walk
|
|
6
|
+
* reaches the top without one.
|
|
7
|
+
*
|
|
8
|
+
* `Program.parent` is `null` rather than `undefined`, so a walk that only
|
|
9
|
+
* guards against `undefined` runs off the top of the tree and throws — which
|
|
10
|
+
* takes the whole lint run for that file down with it.
|
|
11
|
+
*/
|
|
12
|
+
const closestAncestor = (node, matches) => {
|
|
13
|
+
let current = node.parent;
|
|
14
|
+
while (current !== null && current !== undefined) {
|
|
15
|
+
if (matches(current)) {
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
18
|
+
current = current.parent;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
};
|
|
22
|
+
exports.closestAncestor = closestAncestor;
|
package/dist/utils/locals.d.ts
CHANGED
|
@@ -11,3 +11,10 @@ export declare const isReassigned: (variable: TSESLint.Scope.Variable) => boolea
|
|
|
11
11
|
* so such a declaration is load-bearing: inlining it stops compiling.
|
|
12
12
|
*/
|
|
13
13
|
export declare const escapesIntoFunction: (variable: TSESLint.Scope.Variable) => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a name resolves to a binding a `catch` clause introduced.
|
|
16
|
+
* TypeScript types a caught value as `unknown`, so `instanceof` is the only
|
|
17
|
+
* narrowing the language offers there — no method on the value can stand in
|
|
18
|
+
* for it, because at that point the value has no known methods.
|
|
19
|
+
*/
|
|
20
|
+
export declare const isCaughtBinding: (scope: TSESLint.Scope.Scope, name: string) => boolean;
|
package/dist/utils/locals.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.escapesIntoFunction = exports.isReassigned = void 0;
|
|
3
|
+
exports.isCaughtBinding = exports.escapesIntoFunction = exports.isReassigned = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Whether anything writes to the variable after its declaration. Such a local
|
|
6
6
|
* holds mutable state that no member access stands in for, so it is not a copy
|
|
@@ -24,3 +24,21 @@ const escapesIntoFunction = (variable) => variable.references.some((reference) =
|
|
|
24
24
|
return false;
|
|
25
25
|
});
|
|
26
26
|
exports.escapesIntoFunction = escapesIntoFunction;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a name resolves to a binding a `catch` clause introduced.
|
|
29
|
+
* TypeScript types a caught value as `unknown`, so `instanceof` is the only
|
|
30
|
+
* narrowing the language offers there — no method on the value can stand in
|
|
31
|
+
* for it, because at that point the value has no known methods.
|
|
32
|
+
*/
|
|
33
|
+
const isCaughtBinding = (scope, name) => {
|
|
34
|
+
let current = scope;
|
|
35
|
+
while (current !== null) {
|
|
36
|
+
const found = current.variables.find((variable) => variable.name === name);
|
|
37
|
+
if (found !== undefined) {
|
|
38
|
+
return found.defs.some((def) => def.type === 'CatchClause');
|
|
39
|
+
}
|
|
40
|
+
current = current.upper;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
exports.isCaughtBinding = isCaughtBinding;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tianjos/eslint-plugin-elegant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Opinionated ESLint rules for elegant, behavior-rich TypeScript: honest types, encapsulated state, small uncoupled classes, guard-clause flow, and structured logging. Built for NestJS and DDD codebases.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|