@tianjos/eslint-plugin-elegant 0.7.2 → 0.9.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
@@ -63,14 +63,25 @@ export default [
63
63
  ];
64
64
  ```
65
65
 
66
+ Adopting this on a codebase that already exists? Spread
67
+ `elegant.configs.starter` instead — same rules, with the four heaviest demoted
68
+ so the first run gives you a list you can work through. See
69
+ [Adopting on an existing codebase](#adopting-on-an-existing-codebase).
70
+
66
71
  A complete, copy-pasteable example (including test-file overrides) lives in
67
72
  [`eslint.config.example.mjs`](./eslint.config.example.mjs).
68
73
 
69
74
  ## Rules
70
75
 
71
- The `recommended` config enables every custom rule plus two native ones,
72
- [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
73
- [`no-else-return`](https://eslint.org/docs/latest/rules/no-else-return).
76
+ The plugin exports two configs, both carrying every rule below plus two native
77
+ ones, [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
78
+ [`no-else-return`](https://eslint.org/docs/latest/rules/no-else-return):
79
+
80
+ - **`recommended`** — the severities in the table below. What the plugin
81
+ argues for.
82
+ - **`starter`** — the same rules with the four heaviest demoted, for adopting
83
+ on a codebase that already exists. See
84
+ [Adopting on an existing codebase](#adopting-on-an-existing-codebase).
74
85
 
75
86
  | Rule | Source | What it catches | `recommended` |
76
87
  | -------------------------------------- | ------ | ------------------------------------------------------------------------------- | ------------- |
@@ -245,6 +256,44 @@ around repositories and framework hooks, so it stays off in `recommended`.
245
256
  the object. Pairs with `no-type-assertion` to keep type-based branching out of
246
257
  the codebase.
247
258
 
259
+ Two uses are allowed by default, because in both of them TypeScript leaves no
260
+ polymorphic alternative to reach for.
261
+
262
+ **A self-guard** — `other instanceof Money` inside `class Money`. Value
263
+ equality has to guard its own type before comparing fields, and there is no
264
+ polymorphic way to write that: the method is already on the object, so the
265
+ rule's own advice has nowhere left to go. Guarding against a *different* type
266
+ (`other instanceof Currency` inside `Money`) is discrimination and is still
267
+ reported. Off via `{ allowSelfGuard: false }`.
268
+
269
+ **A caught value** — a name a `catch` clause introduced. TypeScript types it
270
+ as `unknown`, so `instanceof` is the only narrowing the language offers; no
271
+ method on the value can stand in, because at that point the value has no known
272
+ methods. Resolved through the scope chain, so the narrowing still counts one
273
+ closure deeper. Off via `{ allowCaughtValues: false }`.
274
+
275
+ ```ts
276
+ // allowed
277
+ class Money {
278
+ equals(other?: unknown): boolean {
279
+ return other instanceof Money && this.amount === other.amount;
280
+ }
281
+ }
282
+ try { charge(); } catch (error) {
283
+ if (error instanceof HttpException) { log(error.getStatus()); }
284
+ }
285
+
286
+ // still reported
287
+ if (shape instanceof Circle) { draw(); }
288
+ function handle(error: HttpException) { return error instanceof HttpException; }
289
+ ```
290
+
291
+ An error that arrives as a plain parameter rather than through `catch` — Nest's
292
+ `ExceptionFilter.catch(exception, host)`, an RxJS `catchError` callback — is
293
+ **not** covered, because a parameter's type is whatever the signature says and
294
+ the rule reads no type information. Narrow it once at the boundary, or disable
295
+ the rule for those files.
296
+
248
297
  #### `no-static-members`
249
298
 
250
299
  Static state and behavior cannot be injected, substituted, or mocked. Prefer
@@ -730,19 +779,31 @@ value from the wire format of a database column. `no-type-assertion` counts
730
779
  `x as unknown as T` twice, once per assertion, which is arguably correct.
731
780
 
732
781
  None of that makes them wrong — it makes them rules you adopt on purpose
733
- rather than inherit. **Enable the preset, then take the top of that table back
734
- to `warn` or `off` and work down it.** The rules below `no-type-assertion` sum
735
- to 1.75 per file, which is a starting point you can actually clear:
782
+ rather than inherit. That is what `starter` is: every rule `recommended`
783
+ carries, with those four demoted, leaving the 1.75 per file below them — a
784
+ list somebody can actually work through.
736
785
 
737
786
  ```js
738
787
  rules: {
739
- ...elegant.configs.recommended.rules,
788
+ ...elegant.configs.starter.rules,
789
+ }
790
+ ```
791
+
792
+ | | `recommended` | `starter` |
793
+ | --- | --- | --- |
794
+ | `no-comments-in-function-body` | `error` | `off` |
795
+ | `no-interpolated-log-message` | `error` | `warn` |
796
+ | `no-null` | `error` | `warn` |
797
+ | `no-type-assertion` | `error` | `warn` |
798
+ | everything else | unchanged | unchanged |
799
+
800
+ Promote them back one at a time as you clear them, and switch to
801
+ `recommended` once nothing is left:
740
802
 
741
- // The four that need a plan of their own. Re-enable one at a time.
742
- 'elegant/no-comments-in-function-body': 'off',
743
- 'elegant/no-interpolated-log-message': 'warn',
744
- 'elegant/no-null': 'warn',
745
- 'elegant/no-type-assertion': 'warn',
803
+ ```js
804
+ rules: {
805
+ ...elegant.configs.starter.rules,
806
+ 'elegant/no-null': 'error', // cleared, so hold the line
746
807
  }
747
808
  ```
748
809
 
@@ -775,7 +836,9 @@ block scoped to your spec globs:
775
836
  The package ships a single CommonJS build that is consumable as both
776
837
  `require('@tianjos/eslint-plugin-elegant')` and an ESM
777
838
  `import elegant from '@tianjos/eslint-plugin-elegant'`. The exported object
778
- exposes `{ meta, rules, configs }`.
839
+ exposes `{ meta, rules, configs }`, where `configs` holds `recommended` and
840
+ `starter`. All three load paths are exercised against the built output by
841
+ `tests/dist.test.ts`.
779
842
 
780
843
  ## Prior art
781
844
 
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", [{
package/dist/index.js CHANGED
@@ -88,5 +88,32 @@ plugin.configs.recommended = {
88
88
  'no-else-return': ['error', { allowElseIf: false }],
89
89
  },
90
90
  };
91
+ /**
92
+ * The four rules that carry most of the friction on code that already exists.
93
+ * Each encodes a defensible position whose boundary this plugin cannot see —
94
+ * a comment that wants to be a function, a logging convention already chosen,
95
+ * `null` as the wire format of a column, an assertion widening a type — so on
96
+ * a mature codebase they report by the thousand. `recommended` keeps them at
97
+ * `error` on purpose; `starter` is the door in.
98
+ */
99
+ const NOISIEST = {
100
+ 'elegant/no-comments-in-function-body': 'off',
101
+ 'elegant/no-interpolated-log-message': 'warn',
102
+ 'elegant/no-null': 'warn',
103
+ 'elegant/no-type-assertion': 'warn',
104
+ };
105
+ /**
106
+ * Every rule `recommended` carries, with the four heaviest demoted so the
107
+ * first run on an existing codebase produces a list somebody can work through.
108
+ * Promote them back one at a time; see "Adopting on an existing codebase".
109
+ */
110
+ plugin.configs.starter = {
111
+ name: 'elegant/starter',
112
+ plugins: { elegant: plugin },
113
+ rules: {
114
+ ...plugin.configs.recommended.rules,
115
+ ...NOISIEST,
116
+ },
117
+ };
91
118
  plugin.default = plugin;
92
119
  module.exports = plugin;
@@ -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.2",
3
+ "version": "0.9.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",
@@ -43,7 +43,7 @@
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "lint": "npm run build && eslint .",
45
45
  "typecheck": "tsc -p tsconfig.test.json",
46
- "test": "jest",
46
+ "test": "npm run build && jest",
47
47
  "release": "standard-version --release-as minor",
48
48
  "release:patch": "standard-version --release-as patch",
49
49
  "prepublishOnly": "npm run build && npm test"