@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 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", [], unknown, TSESLint.RuleListener> & {
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
- declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"noInstanceof", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
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
- let current = node;
34
- while (current !== undefined) {
35
- if (current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
36
- current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
37
- current.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
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
- let current = node;
48
- while (current !== undefined) {
49
- if (current.type === utils_1.AST_NODE_TYPES.MethodDefinition) {
50
- return current.key.type === utils_1.AST_NODE_TYPES.Identifier
51
- ? current.key.name
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 || isDuringConstruction(node)) {
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;
@@ -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;
@@ -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.7.1",
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",