@tianjos/eslint-plugin-elegant 0.6.0 → 0.7.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
@@ -93,6 +93,9 @@ The `recommended` config enables every custom rule plus two native ones,
93
93
  | `elegant/no-property-alias` | custom | Locals that only rename a property of an object already in hand | `error` |
94
94
  | `elegant/no-property-destructuring` | custom | Destructuring an object already in hand into locals | `error` |
95
95
  | `elegant/no-anonymous-param-type` | custom | Parameters typed as an anonymous shape of `minMembers` or more properties | `error` (minMembers 2) |
96
+ | `elegant/no-self-mutation` | custom | Writing to your own fields outside the constructor | `error` |
97
+ | `elegant/no-generic-error` | custom | `throw new Error(...)` and the other built-in error types | `error` |
98
+ | `elegant/max-method-lines` | custom | Named functions and methods longer than `max` lines | `warn` (max 50) |
96
99
  | `max-params` | native | Functions declaring more than `max` parameters | `warn` (max 3) |
97
100
  | `no-else-return` | native | An `else` branch when the `then` branch always returns (`allowElseIf: false`) | `error` |
98
101
 
@@ -183,7 +186,18 @@ throw.
183
186
  #### `no-public-mutable-props`
184
187
 
185
188
  Public state should be `readonly` so callers cannot break an aggregate's
186
- invariants. `private`/`protected` members and `readonly` members are allowed.
189
+ invariants. A declared `private`/`protected` field is allowed, and so is any
190
+ `readonly` member.
191
+
192
+ Constructor parameter properties are covered at **every** visibility by
193
+ default, which declared fields are not: a `private repo: Repository<Proposal>`
194
+ that nobody marked `readonly` can still be swapped from inside, and unlike a
195
+ declared field it is a collaborator the container handed you. Set
196
+ `{ parameterProperties: 'public' }` to keep the rule to what its name says.
197
+
198
+ This rule asks whether a field is *declared* changeable. Its behavioural
199
+ counterpart is `no-self-mutation`, which asks whether anything actually
200
+ changes it.
187
201
 
188
202
  #### `no-logic-in-constructor`
189
203
 
@@ -554,6 +568,80 @@ Unlike its neighbours, this rule does not have a mechanical fix: it asks you to
554
568
  introduce a named type and decide where it lives. That is a design change, so
555
569
  expect adoption to cost more than a find-and-replace.
556
570
 
571
+ #### `no-self-mutation`
572
+
573
+ `no-public-mutable-props` asks whether a field is *declared* changeable. This
574
+ asks whether anything actually changes it. A write to `this.something` after
575
+ the constructor has returned means the object is not a value anyone can hold
576
+ with confidence: whoever received it a moment ago is now holding something
577
+ else.
578
+
579
+ ```ts
580
+ // reported — each one is a lifecycle, not a value
581
+ this.accessToken = access_token;
582
+ this.isPolling = false;
583
+ this.filterOptionsCache = options;
584
+ this.snsClient = new SNSClient({});
585
+
586
+ // passes — this is where an object is built
587
+ constructor(private readonly token: string) {
588
+ this.expiresAt = expiry(token);
589
+ }
590
+ ```
591
+
592
+ Compound assignment (`this.count += 1`) and increment (`this.count++`) are
593
+ writes too. A computed write (`this[key] = value`) names no field and is left
594
+ alone. Writing to *another* object (`box.value = v`) is that object's business.
595
+
596
+ A callback the constructor schedules is **not** construction — it runs after
597
+ the constructor returned, so `setTimeout(() => { this.token = load(); })`
598
+ inside a constructor is reported.
599
+
600
+ Nest calls `onModuleInit`, `onApplicationBootstrap`, `onModuleDestroy`,
601
+ `beforeApplicationShutdown` and `onApplicationShutdown` after the container has
602
+ built the instance, finishing a construction the constructor could not — a
603
+ timer needs a running event loop. Those five are allowed by default, and the
604
+ list is the `{ allowedMethods: string[] }` option; pass `[]` to hold them to
605
+ the same standard, or add your own.
606
+
607
+ #### `no-generic-error`
608
+
609
+ `throw new Error('RETRY_BATCH_QUEUE_URL not configured')` describes the failure
610
+ only in a string the thrower is free to reword. A caller that wants to handle
611
+ that case specifically has nothing to catch but `Error`, which every other
612
+ failure also is, so it ends up matching on the message.
613
+
614
+ ```ts
615
+ // reported
616
+ throw new Error('origin codes are required');
617
+ throw new TypeError('not a number');
618
+
619
+ // passes
620
+ throw new MissingOriginCodes();
621
+ throw new ProposalNotFound(id);
622
+ throw error; // rethrow keeps whatever it was
623
+ throw invalidRow(raw); // a factory decides which exception to build
624
+ ```
625
+
626
+ Covers the eight built-in error types (`Error`, `TypeError`, `RangeError`,
627
+ `ReferenceError`, `SyntaxError`, `EvalError`, `URIError`, `AggregateError`).
628
+ A subclass is a named exception and passes — that is the whole point. Only
629
+ `throw` is examined: building an `Error` to hand to `Promise.reject` or a
630
+ callback is a different question and belongs to a different rule.
631
+
632
+ #### `max-method-lines`
633
+
634
+ A port of Checkstyle's `MethodLength`, which qulice runs. Measured from the
635
+ signature to the closing brace, so the declaration and the blank lines that
636
+ separate the body's paragraphs count — they are part of what a reader has to
637
+ hold.
638
+
639
+ Named units are measured: methods, function declarations, and a function or
640
+ arrow bound to a `const`. An inline callback is **not** measured on its own,
641
+ because a long callback already makes its host long and the host is what gets
642
+ reported. Configurable via `{ max: number }` (default `50`), and a `warn`
643
+ rather than an `error`, like the other thresholds.
644
+
557
645
  ## Configuration
558
646
 
559
647
  ### Overriding thresholds
package/dist/index.d.ts CHANGED
@@ -26,7 +26,9 @@ declare const rules: {
26
26
  'no-null-return': TSESLint.RuleModule<"noNullReturn", [], unknown, TSESLint.RuleListener> & {
27
27
  name: string;
28
28
  };
29
- 'no-public-mutable-props': TSESLint.RuleModule<"mutableProp", [], unknown, TSESLint.RuleListener> & {
29
+ 'no-public-mutable-props': TSESLint.RuleModule<"mutableProp", [{
30
+ parameterProperties: "public" | "all";
31
+ }], unknown, TSESLint.RuleListener> & {
30
32
  name: string;
31
33
  };
32
34
  'no-logic-in-constructor': TSESLint.RuleModule<"statement" | "computation", [], unknown, TSESLint.RuleListener> & {
@@ -82,6 +84,19 @@ declare const rules: {
82
84
  }], unknown, TSESLint.RuleListener> & {
83
85
  name: string;
84
86
  };
87
+ 'no-self-mutation': TSESLint.RuleModule<"mutatesSelf", [{
88
+ allowedMethods: string[];
89
+ }], unknown, TSESLint.RuleListener> & {
90
+ name: string;
91
+ };
92
+ 'no-generic-error': TSESLint.RuleModule<"genericError", [], unknown, TSESLint.RuleListener> & {
93
+ name: string;
94
+ };
95
+ 'max-method-lines': TSESLint.RuleModule<"tooManyLines", [{
96
+ max: number;
97
+ }], unknown, TSESLint.RuleListener> & {
98
+ name: string;
99
+ };
85
100
  };
86
101
  type Plugin = {
87
102
  meta: {
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  const no_anonymous_param_type_1 = __importDefault(require("./rules/no-anonymous-param-type"));
6
6
  const max_class_dependencies_1 = __importDefault(require("./rules/max-class-dependencies"));
7
+ const max_method_lines_1 = __importDefault(require("./rules/max-method-lines"));
7
8
  const max_class_fields_1 = __importDefault(require("./rules/max-class-fields"));
8
9
  const max_returns_1 = __importDefault(require("./rules/max-returns"));
9
10
  const max_class_methods_1 = __importDefault(require("./rules/max-class-methods"));
@@ -15,7 +16,9 @@ const no_instanceof_1 = __importDefault(require("./rules/no-instanceof"));
15
16
  const no_interpolated_log_message_1 = __importDefault(require("./rules/no-interpolated-log-message"));
16
17
  const no_logic_in_constructor_1 = __importDefault(require("./rules/no-logic-in-constructor"));
17
18
  const no_null_1 = __importDefault(require("./rules/no-null"));
19
+ const no_generic_error_1 = __importDefault(require("./rules/no-generic-error"));
18
20
  const no_null_return_1 = __importDefault(require("./rules/no-null-return"));
21
+ const no_self_mutation_1 = __importDefault(require("./rules/no-self-mutation"));
19
22
  const no_property_alias_1 = __importDefault(require("./rules/no-property-alias"));
20
23
  const no_property_destructuring_1 = __importDefault(require("./rules/no-property-destructuring"));
21
24
  const no_public_mutable_props_1 = __importDefault(require("./rules/no-public-mutable-props"));
@@ -46,6 +49,9 @@ const rules = {
46
49
  'no-property-alias': no_property_alias_1.default,
47
50
  'no-property-destructuring': no_property_destructuring_1.default,
48
51
  'no-anonymous-param-type': no_anonymous_param_type_1.default,
52
+ 'no-self-mutation': no_self_mutation_1.default,
53
+ 'no-generic-error': no_generic_error_1.default,
54
+ 'max-method-lines': max_method_lines_1.default,
49
55
  };
50
56
  const plugin = {
51
57
  meta: { name, version },
@@ -75,6 +81,9 @@ plugin.configs.recommended = {
75
81
  'elegant/no-property-alias': 'error',
76
82
  'elegant/no-property-destructuring': 'error',
77
83
  'elegant/no-anonymous-param-type': ['error', { minMembers: 2 }],
84
+ 'elegant/no-self-mutation': 'error',
85
+ 'elegant/no-generic-error': 'error',
86
+ 'elegant/max-method-lines': ['warn', { max: 50 }],
78
87
  'max-params': ['warn', { max: 3 }],
79
88
  'no-else-return': ['error', { allowElseIf: false }],
80
89
  },
@@ -0,0 +1,7 @@
1
+ type Options = [{
2
+ max: number;
3
+ }];
4
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"tooManyLines", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
5
+ name: string;
6
+ };
7
+ export default _default;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@typescript-eslint/utils");
4
+ const createRule_1 = require("../utils/createRule");
5
+ const DEFAULT_MAX = 50;
6
+ exports.default = (0, createRule_1.createRule)({
7
+ name: 'max-method-lines',
8
+ meta: {
9
+ type: 'suggestion',
10
+ docs: {
11
+ description: 'Enforce a maximum length, in lines, for a named function or method. An inline callback is measured through the function hosting it.',
12
+ },
13
+ messages: {
14
+ tooManyLines: "'{{name}}' spans {{count}} lines (max {{max}}). A body that long is holding several ideas; give each one a name.",
15
+ },
16
+ schema: [
17
+ {
18
+ type: 'object',
19
+ properties: { max: { type: 'integer', minimum: 1 } },
20
+ additionalProperties: false,
21
+ },
22
+ ],
23
+ },
24
+ defaultOptions: [{ max: DEFAULT_MAX }],
25
+ create(context, [{ max }]) {
26
+ const measure = (node, name, key) => {
27
+ const count = node.loc.end.line - node.loc.start.line + 1;
28
+ if (count <= max) {
29
+ return;
30
+ }
31
+ context.report({
32
+ node: key,
33
+ messageId: 'tooManyLines',
34
+ data: { name, count, max },
35
+ });
36
+ };
37
+ return {
38
+ MethodDefinition(node) {
39
+ if (node.key.type !== utils_1.AST_NODE_TYPES.Identifier) {
40
+ return;
41
+ }
42
+ measure(node, node.key.name, node.key);
43
+ },
44
+ FunctionDeclaration(node) {
45
+ if (node.id === null) {
46
+ return;
47
+ }
48
+ measure(node, node.id.name, node.id);
49
+ },
50
+ VariableDeclarator(node) {
51
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
52
+ node.init === null ||
53
+ (node.init.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
54
+ node.init.type !== utils_1.AST_NODE_TYPES.FunctionExpression)) {
55
+ return;
56
+ }
57
+ measure(node.init, node.id.name, node.id);
58
+ },
59
+ };
60
+ },
61
+ });
@@ -0,0 +1,4 @@
1
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"genericError", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
4
+ export default _default;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@typescript-eslint/utils");
4
+ const createRule_1 = require("../utils/createRule");
5
+ /**
6
+ * The error types the language ships. Each says only "something went wrong",
7
+ * so every caller that wants to distinguish failures ends up matching on the
8
+ * message — a string the thrower is free to reword.
9
+ */
10
+ const BUILT_IN_ERRORS = new Set([
11
+ 'Error',
12
+ 'EvalError',
13
+ 'RangeError',
14
+ 'ReferenceError',
15
+ 'SyntaxError',
16
+ 'TypeError',
17
+ 'URIError',
18
+ 'AggregateError',
19
+ ]);
20
+ exports.default = (0, createRule_1.createRule)({
21
+ name: 'no-generic-error',
22
+ meta: {
23
+ type: 'suggestion',
24
+ docs: {
25
+ description: 'Disallow throwing the built-in error types. A named exception carries the failure in its type, where a caller can catch exactly it.',
26
+ },
27
+ messages: {
28
+ genericError: "Throwing '{{name}}' leaves the failure describable only by its message. Throw a named exception the caller can catch by type.",
29
+ },
30
+ schema: [],
31
+ },
32
+ defaultOptions: [],
33
+ create(context) {
34
+ return {
35
+ ThrowStatement(node) {
36
+ if (node.argument.type !== utils_1.AST_NODE_TYPES.NewExpression ||
37
+ node.argument.callee.type !== utils_1.AST_NODE_TYPES.Identifier ||
38
+ !BUILT_IN_ERRORS.has(node.argument.callee.name)) {
39
+ return;
40
+ }
41
+ context.report({
42
+ node,
43
+ messageId: 'genericError',
44
+ data: { name: node.argument.callee.name },
45
+ });
46
+ },
47
+ };
48
+ },
49
+ });
@@ -1,4 +1,7 @@
1
- declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"mutableProp", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
1
+ type Options = [{
2
+ parameterProperties: 'public' | 'all';
3
+ }];
4
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"mutableProp", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
5
  name: string;
3
6
  };
4
7
  export default _default;
@@ -13,11 +13,14 @@ const keyName = (key) => {
13
13
  return 'property';
14
14
  };
15
15
  /**
16
- * A constructor parameter property the outside world can write to. Only
17
- * constructor parameter properties carry an accessibility modifier, so reading
18
- * one is enough to know the field is public.
16
+ * A constructor parameter property that can be written to. At `'all'` the
17
+ * visibility stops mattering: a `private` collaborator nobody marked readonly
18
+ * can still be swapped from inside, which is the same defect one wall further
19
+ * in. A parameter property with no modifier at all declares no field.
19
20
  */
20
- const isPublicMutable = (node) => node.accessibility === 'public' && !node.readonly;
21
+ const isMutable = (node, scope) => !node.readonly &&
22
+ node.accessibility !== undefined &&
23
+ (scope === 'all' || node.accessibility === 'public');
21
24
  exports.default = (0, createRule_1.createRule)({
22
25
  name: 'no-public-mutable-props',
23
26
  meta: {
@@ -26,12 +29,20 @@ exports.default = (0, createRule_1.createRule)({
26
29
  description: 'Disallow public mutable class properties. Public state should be readonly to protect invariants and preserve encapsulation.',
27
30
  },
28
31
  messages: {
29
- mutableProp: "Public property '{{name}}' is mutable. Make it readonly or expose it through a method that protects the invariant.",
32
+ mutableProp: "Property '{{name}}' is mutable. Make it readonly or expose it through a method that protects the invariant.",
30
33
  },
31
- schema: [],
34
+ schema: [
35
+ {
36
+ type: 'object',
37
+ properties: {
38
+ parameterProperties: { type: 'string', enum: ['public', 'all'] },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ ],
32
43
  },
33
- defaultOptions: [],
34
- create(context) {
44
+ defaultOptions: [{ parameterProperties: 'all' }],
45
+ create(context, [{ parameterProperties }]) {
35
46
  return {
36
47
  PropertyDefinition(node) {
37
48
  if (node.readonly || isHidden(node.accessibility)) {
@@ -44,7 +55,7 @@ exports.default = (0, createRule_1.createRule)({
44
55
  });
45
56
  },
46
57
  TSParameterProperty(node) {
47
- if (!isPublicMutable(node)) {
58
+ if (!isMutable(node, parameterProperties)) {
48
59
  return;
49
60
  }
50
61
  const target = node.parameter.type === utils_1.AST_NODE_TYPES.AssignmentPattern
@@ -0,0 +1,7 @@
1
+ type Options = [{
2
+ allowedMethods: string[];
3
+ }];
4
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"mutatesSelf", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
5
+ name: string;
6
+ };
7
+ export default _default;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@typescript-eslint/utils");
4
+ const createRule_1 = require("../utils/createRule");
5
+ /**
6
+ * Nest calls these after the container has built the instance, so they finish
7
+ * a construction the constructor could not: a timer needs a running event
8
+ * loop, a pool needs a connection. Teardown is the same seam in reverse.
9
+ */
10
+ const LIFECYCLE_HOOKS = [
11
+ 'onModuleInit',
12
+ 'onApplicationBootstrap',
13
+ 'onModuleDestroy',
14
+ 'beforeApplicationShutdown',
15
+ 'onApplicationShutdown',
16
+ ];
17
+ /**
18
+ * The field a write targets, when the target is a field of `this`. A computed
19
+ * write (`this[key] = value`) names no field and is left to other rules.
20
+ */
21
+ const fieldOf = (target) => target.type === utils_1.AST_NODE_TYPES.MemberExpression &&
22
+ !target.computed &&
23
+ target.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
24
+ target.property.type === utils_1.AST_NODE_TYPES.Identifier
25
+ ? target.property.name
26
+ : undefined;
27
+ /**
28
+ * Whether the write happens while the object is still being built. Only the
29
+ * constructor's own body counts: a callback the constructor schedules runs
30
+ * after construction has returned, so it mutates a finished object.
31
+ */
32
+ 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;
44
+ };
45
+ /** The method a write sits in, if it sits in one directly. */
46
+ 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;
57
+ };
58
+ exports.default = (0, createRule_1.createRule)({
59
+ name: 'no-self-mutation',
60
+ meta: {
61
+ type: 'suggestion',
62
+ docs: {
63
+ description: 'Disallow writing to your own fields outside the constructor. An object that changes after construction is holding a lifecycle, not modelling a value.',
64
+ },
65
+ messages: {
66
+ mutatesSelf: "'this.{{name}}' is written after construction. Build a new object instead of letting this one change underneath its holders.",
67
+ },
68
+ schema: [
69
+ {
70
+ type: 'object',
71
+ properties: {
72
+ allowedMethods: { type: 'array', items: { type: 'string' } },
73
+ },
74
+ additionalProperties: false,
75
+ },
76
+ ],
77
+ },
78
+ defaultOptions: [{ allowedMethods: LIFECYCLE_HOOKS }],
79
+ create(context, [{ allowedMethods }]) {
80
+ const check = (node, target) => {
81
+ const name = fieldOf(target);
82
+ if (name === undefined || isDuringConstruction(node)) {
83
+ return;
84
+ }
85
+ const method = enclosingMethod(node);
86
+ if (method !== undefined && allowedMethods.includes(method)) {
87
+ return;
88
+ }
89
+ context.report({ node, messageId: 'mutatesSelf', data: { name } });
90
+ };
91
+ return {
92
+ AssignmentExpression(node) {
93
+ check(node, node.left);
94
+ },
95
+ UpdateExpression(node) {
96
+ check(node, node.argument);
97
+ },
98
+ };
99
+ },
100
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianjos/eslint-plugin-elegant",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",