@tianjos/eslint-plugin-elegant 0.4.0 → 0.5.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
@@ -90,6 +90,9 @@ The `recommended` config enables every custom rule plus two native ones,
90
90
  | `elegant/no-else-after-throw` | custom | An `else` branch when the `then` branch always throws | `error` |
91
91
  | `elegant/no-interpolated-log-message` | custom | Log messages built by interpolation or concatenation | `error` |
92
92
  | `elegant/max-returns` | custom | Functions returning from more places than `max` | `warn` (max 3) |
93
+ | `elegant/no-property-alias` | custom | Locals that only rename a property of an object already in hand | `error` |
94
+ | `elegant/no-property-destructuring` | custom | Destructuring an object already in hand into locals | `error` |
95
+ | `elegant/no-anonymous-param-type` | custom | Parameters typed as an anonymous shape of `minMembers` or more properties | `error` (minMembers 2) |
93
96
  | `max-params` | native | Functions declaring more than `max` parameters | `warn` (max 3) |
94
97
  | `no-else-return` | native | An `else` branch when the `then` branch always returns (`allowElseIf: false`) | `error` |
95
98
 
@@ -371,6 +374,148 @@ method's key, or the `const` or class field holding an arrow — falling back to
371
374
  `(anonymous)` for an inline callback. Configurable via `{ max: number }`
372
375
  (default `3`).
373
376
 
377
+ #### `no-property-alias`
378
+
379
+ A local whose whole job is to hold `obj.status` is a second name for state the
380
+ object already exposes under a name of its own. It buys nothing and it costs a
381
+ reader the hop of proving the two are the same value. Ask the object where you
382
+ need the answer.
383
+
384
+ ```ts
385
+ // reported
386
+ const objStatus = obj.status;
387
+ const authHeader = request.headers.authorization;
388
+ const region = this.cognitoRegion;
389
+
390
+ // passes
391
+ obj.status;
392
+ request.headers.authorization;
393
+ this.cognitoRegion;
394
+ ```
395
+
396
+ Only variable declarations are reported. `this.total = other.total` transfers
397
+ state rather than aliasing it, and a property in an object literal
398
+ (`{ id: dto.id }`) is how mappers are written; neither trips the rule.
399
+
400
+ Four shapes are never reported, because in each of them the local is doing real
401
+ work:
402
+
403
+ - **A reassigned local.** `let status = obj.status` followed by
404
+ `status = 'EXPIRED'` holds mutable state that no member access stands in for.
405
+ - **A local read inside a nested function.** TypeScript drops a narrowing of
406
+ `obj.prop` at the callback boundary but keeps it on a local, so inlining such
407
+ a declaration stops compiling:
408
+
409
+ ```ts
410
+ const status = obj.status;
411
+ if (status === undefined) return [];
412
+ return obj.items.map((n) => n + status.length); // needs the local
413
+ ```
414
+
415
+ - **A chain that is not a plain run of `.prop` accesses.** A computed link
416
+ (`repo.save.mock.calls[1][0].metadata`) or a call in the middle
417
+ (`resolveDates(query).startDate`) is not a property of an object in hand, and
418
+ repeating it reads worse than naming it.
419
+ - **An environment read.** `const topicArn = process.env.SNS_ERROR_TOPIC`
420
+ followed by a guard is fail-fast, and inlining it would read the environment
421
+ twice. Set `{ allowEnv: false }` to hold these to the same standard.
422
+
423
+ Its sibling `no-property-destructuring` covers the same reach-in written as a
424
+ pattern; together they say one thing, which is to ask the object.
425
+
426
+ This rule is the mirror image of ESLint's native
427
+ [`prefer-destructuring`](https://eslint.org/docs/latest/rules/prefer-destructuring),
428
+ which reports `const status = obj.status` and asks you to write
429
+ `const { status } = obj` instead. The two cannot both be on. `prefer-destructuring`
430
+ is off by default, so there is nothing to undo unless you enabled it — and note
431
+ that it only fires when the local and the property share a name, leaving the
432
+ renaming majority (`const objStatus = obj.status`) unreported either way.
433
+
434
+ #### `no-property-destructuring`
435
+
436
+ `const { status, enabled } = obj` is `no-property-alias` written as a pattern:
437
+ the object already names its own state, and the locals are a second set of
438
+ names for it. This rule covers the pattern form, and only when the thing being
439
+ destructured is an object you already hold — a name, `this`, or a run of plain
440
+ `.prop` accesses rooted at one of those.
441
+
442
+ ```ts
443
+ // reported
444
+ const { status, enabled } = obj;
445
+ const { access_token, expires_in } = response.data;
446
+ const { region, poolId } = this.config;
447
+
448
+ // passes — none of these was an object in hand
449
+ function create({ id, name }) {}
450
+ for (const { id, total } of rows) {}
451
+ const { csvContent } = await service.exportCsv(query);
452
+ const { startDate } = resolveDates(query);
453
+ const [rows, total] = await repo.findAndCount();
454
+ ```
455
+
456
+ Parameter patterns, loop bindings, and `catch` bindings are how you receive a
457
+ value rather than reach into one, so they never come up. Neither does
458
+ `ArrayPattern`: `const [rows, total] = ...` names the halves of a tuple that
459
+ carries no names of its own.
460
+
461
+ Four shapes are never reported, because in each of them the pattern is doing
462
+ work no member access does:
463
+
464
+ - **A rest element.** `const { authorization: _auth, ...safe } = headers`
465
+ constructs a new object by omission. There is nothing to inline it into.
466
+ - **A default value.** `const { max = 3 } = options` inlines to
467
+ `options.max ?? 3`, repeating the fallback at every use site.
468
+ - **A local read inside a nested function**, for the narrowing reason spelled
469
+ out under `no-property-alias`.
470
+ - **A local reassigned later.** `let { status } = obj` followed by
471
+ `status = 'EXPIRED'` holds mutable state of its own.
472
+
473
+ Renaming on the way out (`const { ingestion: failure } = row`) is still
474
+ copying, and so is a single property. Width makes no difference: a pattern
475
+ pulling four fields off an `input` is usually the sign that the method wanted
476
+ the object, not the fields.
477
+
478
+ #### `no-anonymous-param-type`
479
+
480
+ `max-params` and `no-boolean-param` both push you towards an options object —
481
+ and an options object typed inline is a bag that got away with it. The
482
+ parameter count went down, the coupling did not, and the shape has nowhere to
483
+ grow behaviour. Give it a name and it can become a value object; leave it
484
+ anonymous and it stays a struct.
485
+
486
+ ```ts
487
+ // reported
488
+ private toResponse(group: { id: string; name: string; members: number }) {}
489
+ async createFundingProducts(data: { originCode: string; productId: string }) {}
490
+ chart(rows: Array<{ day: string; count: string }>) {}
491
+ constructor(private readonly config: { host: string; port: number }) {}
492
+
493
+ // passes
494
+ async register(input: RegisterProposal) {}
495
+ function charge(amount: number, currency: string) {}
496
+ ingest(raw: Record<string, unknown>) {}
497
+ ```
498
+
499
+ A shape counts wherever it hides in the annotation — on its own, in a union
500
+ with `null`, intersected onto a named type, in an array, or inside a generic
501
+ argument such as `Array<{ … }>`. A parameter is reported once however many
502
+ shapes it holds, and each offending parameter is reported separately.
503
+ Destructuring in the signature (`function create({ id }: { id: string })`)
504
+ does not hide the bag, and neither does a default value.
505
+
506
+ **Inline callbacks are never reported.** `res.body.items.map((i: { ccbNumber: string; total: number }) => i.total)`
507
+ annotates whatever the callee yields; when that value has no type to borrow, an
508
+ inline shape is the only way to type it at all.
509
+
510
+ Configurable via `{ minMembers: number }` (default `2`). At the default, a
511
+ one-property parameter like `opts?: { required?: boolean }` passes — naming a
512
+ single field is usually ceremony rather than design. Set `minMembers: 1` to
513
+ hold those to the same standard.
514
+
515
+ Unlike its neighbours, this rule does not have a mechanical fix: it asks you to
516
+ introduce a named type and decide where it lives. That is a design change, so
517
+ expect adoption to cost more than a find-and-replace.
518
+
374
519
  ## Configuration
375
520
 
376
521
  ### Overriding thresholds
package/dist/index.d.ts CHANGED
@@ -67,6 +67,19 @@ declare const rules: {
67
67
  }], unknown, TSESLint.RuleListener> & {
68
68
  name: string;
69
69
  };
70
+ 'no-property-alias': TSESLint.RuleModule<"aliasesProperty", [{
71
+ allowEnv: boolean;
72
+ }], unknown, TSESLint.RuleListener> & {
73
+ name: string;
74
+ };
75
+ 'no-property-destructuring': TSESLint.RuleModule<"destructuresObject", [], unknown, TSESLint.RuleListener> & {
76
+ name: string;
77
+ };
78
+ 'no-anonymous-param-type': TSESLint.RuleModule<"anonymousParamType", [{
79
+ minMembers: number;
80
+ }], unknown, TSESLint.RuleListener> & {
81
+ name: string;
82
+ };
70
83
  };
71
84
  type Plugin = {
72
85
  meta: {
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
+ const no_anonymous_param_type_1 = __importDefault(require("./rules/no-anonymous-param-type"));
5
6
  const max_class_dependencies_1 = __importDefault(require("./rules/max-class-dependencies"));
6
7
  const max_class_fields_1 = __importDefault(require("./rules/max-class-fields"));
7
8
  const max_returns_1 = __importDefault(require("./rules/max-returns"));
@@ -15,9 +16,15 @@ const no_interpolated_log_message_1 = __importDefault(require("./rules/no-interp
15
16
  const no_logic_in_constructor_1 = __importDefault(require("./rules/no-logic-in-constructor"));
16
17
  const no_null_1 = __importDefault(require("./rules/no-null"));
17
18
  const no_null_return_1 = __importDefault(require("./rules/no-null-return"));
19
+ const no_property_alias_1 = __importDefault(require("./rules/no-property-alias"));
20
+ const no_property_destructuring_1 = __importDefault(require("./rules/no-property-destructuring"));
18
21
  const no_public_mutable_props_1 = __importDefault(require("./rules/no-public-mutable-props"));
19
22
  const no_static_members_1 = __importDefault(require("./rules/no-static-members"));
20
23
  const no_type_assertion_1 = __importDefault(require("./rules/no-type-assertion"));
24
+ // require() of a JSON file yields `any`, so there is no honest type to reach
25
+ // for here. Importing it instead would put package.json inside the emitted
26
+ // tree and move dist/index.js, which the "exports" map pins.
27
+ // eslint-disable-next-line elegant/no-type-assertion
21
28
  const { name, version } = require('../package.json');
22
29
  const rules = {
23
30
  'no-boolean-param': no_boolean_param_1.default,
@@ -36,6 +43,9 @@ const rules = {
36
43
  'no-else-after-throw': no_else_after_throw_1.default,
37
44
  'no-interpolated-log-message': no_interpolated_log_message_1.default,
38
45
  'max-returns': max_returns_1.default,
46
+ 'no-property-alias': no_property_alias_1.default,
47
+ 'no-property-destructuring': no_property_destructuring_1.default,
48
+ 'no-anonymous-param-type': no_anonymous_param_type_1.default,
39
49
  };
40
50
  const plugin = {
41
51
  meta: { name, version },
@@ -62,6 +72,9 @@ plugin.configs.recommended = {
62
72
  'elegant/no-comments-in-function-body': 'error',
63
73
  'elegant/no-else-after-throw': 'error',
64
74
  'elegant/no-interpolated-log-message': 'error',
75
+ 'elegant/no-property-alias': 'error',
76
+ 'elegant/no-property-destructuring': 'error',
77
+ 'elegant/no-anonymous-param-type': ['error', { minMembers: 2 }],
65
78
  'max-params': ['warn', { max: 3 }],
66
79
  'no-else-return': ['error', { allowElseIf: false }],
67
80
  },
@@ -39,9 +39,10 @@ const injected = (body, sourceCode) => {
39
39
  };
40
40
  const instantiated = (node, sourceCode) => {
41
41
  const root = sourceCode.getText(node.callee);
42
- const args = node.typeArguments;
43
42
  return {
44
- key: `${root}${args === undefined ? '' : sourceCode.getText(args)}`,
43
+ key: `${root}${node.typeArguments === undefined
44
+ ? ''
45
+ : sourceCode.getText(node.typeArguments)}`,
45
46
  root,
46
47
  };
47
48
  };
@@ -95,12 +96,11 @@ exports.default = (0, createRule_1.createRule)({
95
96
  if (dependencies.size <= max) {
96
97
  return;
97
98
  }
98
- const classNode = node.parent;
99
99
  context.report({
100
- node: classNode.id ?? node,
100
+ node: node.parent.id ?? node,
101
101
  messageId: 'tooManyDependencies',
102
102
  data: {
103
- name: classNode.id?.name ?? '(anonymous)',
103
+ name: node.parent.id?.name ?? '(anonymous)',
104
104
  count: dependencies.size,
105
105
  max,
106
106
  names: [...dependencies].join(', '),
@@ -7,16 +7,16 @@ const isField = (member) => member.type === utils_1.AST_NODE_TYPES.PropertyDefin
7
7
  member.type === utils_1.AST_NODE_TYPES.TSAbstractPropertyDefinition ||
8
8
  member.type === utils_1.AST_NODE_TYPES.AccessorProperty;
9
9
  const named = (key, sourceCode) => key.type === utils_1.AST_NODE_TYPES.Identifier ? key.name : sourceCode.getText(key);
10
+ /** Every property declared in the class body. */
11
+ const declared = (member, sourceCode) => isField(member) && !member.static ? [named(member.key, sourceCode)] : [];
10
12
  /**
11
- * Properties declared in the class body. A decorated one is skipped by default:
12
- * `@Column`, `@IsString` and friends map a field to a table or a payload, which
13
- * is framework shape rather than state the class chose to carry.
13
+ * The same, minus the decorated ones: `@Column`, `@IsString` and friends map a
14
+ * field to a table or a payload, which is framework shape rather than state the
15
+ * class chose to carry.
14
16
  */
15
- const declared = (member, sourceCode, ignoreDecorated) => isField(member) &&
16
- !member.static &&
17
- !(ignoreDecorated && member.decorators.length > 0)
18
- ? [named(member.key, sourceCode)]
19
- : [];
17
+ const declaredUndecorated = (member, sourceCode) => isField(member) && member.decorators.length > 0
18
+ ? []
19
+ : declared(member, sourceCode);
20
20
  /**
21
21
  * Constructor parameter properties. `ignoreDecorated` deliberately does not
22
22
  * reach them: a decorator on a parameter is injection (`@Inject(TOKEN)`), so the
@@ -55,21 +55,21 @@ exports.default = (0, createRule_1.createRule)({
55
55
  defaultOptions: [{ max: DEFAULT_MAX, ignoreDecorated: true }],
56
56
  create(context, [{ max, ignoreDecorated }]) {
57
57
  const sourceCode = context.sourceCode;
58
+ const fieldsOf = ignoreDecorated ? declaredUndecorated : declared;
58
59
  return {
59
60
  ClassBody(node) {
60
61
  const fields = node.body.flatMap((member) => [
61
- ...declared(member, sourceCode, ignoreDecorated),
62
+ ...fieldsOf(member, sourceCode),
62
63
  ...promoted(member, sourceCode),
63
64
  ]);
64
65
  if (fields.length <= max) {
65
66
  return;
66
67
  }
67
- const classNode = node.parent;
68
68
  context.report({
69
- node: classNode.id ?? node,
69
+ node: node.parent.id ?? node,
70
70
  messageId: 'tooManyFields',
71
71
  data: {
72
- name: classNode.id?.name ?? '(anonymous)',
72
+ name: node.parent.id?.name ?? '(anonymous)',
73
73
  count: fields.length,
74
74
  max,
75
75
  names: fields.join(', '),
@@ -32,10 +32,9 @@ exports.default = (0, createRule_1.createRule)({
32
32
  if (methods.length <= max) {
33
33
  return;
34
34
  }
35
- const classNode = node.parent;
36
- const name = classNode.id?.name ?? '(anonymous)';
35
+ const name = node.parent.id?.name ?? '(anonymous)';
37
36
  context.report({
38
- node: classNode.id ?? node,
37
+ node: node.parent.id ?? node,
39
38
  messageId: 'tooManyMethods',
40
39
  data: { name, count: methods.length, max },
41
40
  });
@@ -12,17 +12,16 @@ const nameOf = (node) => {
12
12
  if (node.id !== null) {
13
13
  return node.id.name;
14
14
  }
15
- const parent = node.parent;
16
- if ((parent.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
17
- parent.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
18
- parent.type === utils_1.AST_NODE_TYPES.Property) &&
19
- !parent.computed &&
20
- parent.key.type === utils_1.AST_NODE_TYPES.Identifier) {
21
- return parent.key.name;
15
+ if ((node.parent.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
16
+ node.parent.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
17
+ node.parent.type === utils_1.AST_NODE_TYPES.Property) &&
18
+ !node.parent.computed &&
19
+ node.parent.key.type === utils_1.AST_NODE_TYPES.Identifier) {
20
+ return node.parent.key.name;
22
21
  }
23
- if (parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
24
- parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
25
- return parent.id.name;
22
+ if (node.parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
23
+ node.parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
24
+ return node.parent.id.name;
26
25
  }
27
26
  return '(anonymous)';
28
27
  };
@@ -58,14 +57,13 @@ exports.default = (0, createRule_1.createRule)({
58
57
  if (scope === undefined || scope.count <= max) {
59
58
  return;
60
59
  }
61
- const { node, count } = scope;
62
- const signature = node.id ?? sourceCode.getFirstToken(node);
60
+ const signature = scope.node.id ?? sourceCode.getFirstToken(scope.node);
63
61
  context.report({
64
- loc: (signature ?? node).loc,
62
+ loc: (signature ?? scope.node).loc,
65
63
  messageId: 'tooManyReturns',
66
64
  data: {
67
- name: nameOf(node),
68
- count,
65
+ name: nameOf(scope.node),
66
+ count: scope.count,
69
67
  max,
70
68
  },
71
69
  });
@@ -0,0 +1,7 @@
1
+ type Options = [{
2
+ minMembers: number;
3
+ }];
4
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"anonymousParamType", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
5
+ name: string;
6
+ };
7
+ export default _default;
@@ -0,0 +1,120 @@
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_MIN_MEMBERS = 2;
6
+ const bindingOf = (param) => {
7
+ if (param.type === utils_1.AST_NODE_TYPES.TSParameterProperty) {
8
+ return bindingOf(param.parameter);
9
+ }
10
+ const target = param.type === utils_1.AST_NODE_TYPES.AssignmentPattern ? param.left : param;
11
+ switch (target.type) {
12
+ case utils_1.AST_NODE_TYPES.ArrayPattern:
13
+ case utils_1.AST_NODE_TYPES.Identifier:
14
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
15
+ case utils_1.AST_NODE_TYPES.RestElement:
16
+ return target;
17
+ default:
18
+ return undefined;
19
+ }
20
+ };
21
+ const nameOf = (binding) => binding.type === utils_1.AST_NODE_TYPES.Identifier ? binding.name : '(destructured)';
22
+ /** The type nodes a shape can hide inside, one level down. */
23
+ const reachableFrom = (node) => {
24
+ switch (node.type) {
25
+ case utils_1.AST_NODE_TYPES.TSTypeReference:
26
+ return node.typeArguments?.params ?? [];
27
+ case utils_1.AST_NODE_TYPES.TSUnionType:
28
+ case utils_1.AST_NODE_TYPES.TSIntersectionType:
29
+ return node.types;
30
+ case utils_1.AST_NODE_TYPES.TSArrayType:
31
+ return [node.elementType];
32
+ default:
33
+ return [];
34
+ }
35
+ };
36
+ /**
37
+ * The first anonymous shape of at least `minMembers` properties reachable from
38
+ * a type annotation. Shapes hide behind generic arguments as readily as they
39
+ * sit in the open — `Array<{ day; count }>` is as unnamed as `{ day; count }`.
40
+ */
41
+ const shapeIn = (node, minMembers) => {
42
+ if (node.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
43
+ return node.members.length >= minMembers ? node : undefined;
44
+ }
45
+ for (const inner of reachableFrom(node)) {
46
+ const shape = shapeIn(inner, minMembers);
47
+ if (shape !== undefined) {
48
+ return shape;
49
+ }
50
+ }
51
+ return undefined;
52
+ };
53
+ /**
54
+ * Whether the signature is a function handed straight to a call. Such a
55
+ * parameter annotates whatever the callee yields — often an untyped response
56
+ * body — so an inline shape is the only way to type it at all.
57
+ */
58
+ const isInlineCallback = (node) => (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
59
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression) &&
60
+ node.parent.type === utils_1.AST_NODE_TYPES.CallExpression;
61
+ exports.default = (0, createRule_1.createRule)({
62
+ name: 'no-anonymous-param-type',
63
+ meta: {
64
+ type: 'suggestion',
65
+ docs: {
66
+ description: 'Disallow parameters typed as an anonymous shape. Name the shape so it can carry behaviour instead of being a bag.',
67
+ },
68
+ messages: {
69
+ anonymousParamType: "Parameter '{{name}}' is typed as an anonymous shape of {{count}} properties. Name the shape so it can carry behaviour instead of being a bag.",
70
+ },
71
+ schema: [
72
+ {
73
+ type: 'object',
74
+ properties: {
75
+ minMembers: { type: 'integer', minimum: 1 },
76
+ },
77
+ additionalProperties: false,
78
+ },
79
+ ],
80
+ },
81
+ defaultOptions: [{ minMembers: DEFAULT_MIN_MEMBERS }],
82
+ create(context, [{ minMembers }]) {
83
+ const check = (node) => {
84
+ if (isInlineCallback(node)) {
85
+ return;
86
+ }
87
+ for (const param of node.params) {
88
+ const binding = bindingOf(param);
89
+ if (binding === undefined) {
90
+ continue;
91
+ }
92
+ const annotation = binding.typeAnnotation?.typeAnnotation;
93
+ if (annotation === undefined) {
94
+ continue;
95
+ }
96
+ const shape = shapeIn(annotation, minMembers);
97
+ if (shape === undefined) {
98
+ continue;
99
+ }
100
+ context.report({
101
+ node: param,
102
+ messageId: 'anonymousParamType',
103
+ data: {
104
+ name: nameOf(binding),
105
+ count: shape.members.length,
106
+ },
107
+ });
108
+ }
109
+ };
110
+ return {
111
+ ArrowFunctionExpression: check,
112
+ FunctionDeclaration: check,
113
+ FunctionExpression: check,
114
+ TSDeclareFunction: check,
115
+ TSEmptyBodyFunctionExpression: check,
116
+ TSFunctionType: check,
117
+ TSMethodSignature: check,
118
+ };
119
+ },
120
+ });
@@ -64,14 +64,13 @@ exports.default = (0, createRule_1.createRule)({
64
64
  const levels = new Set([...METHODS, ...methods]);
65
65
  return {
66
66
  CallExpression(node) {
67
- const callee = node.callee;
68
- if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
69
- callee.computed ||
70
- callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
71
- !levels.has(callee.property.name)) {
67
+ if (node.callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
68
+ node.callee.computed ||
69
+ node.callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
70
+ !levels.has(node.callee.property.name)) {
72
71
  return;
73
72
  }
74
- const name = receiver(callee.object);
73
+ const name = receiver(node.callee.object);
75
74
  if (name === undefined || !logging.has(name)) {
76
75
  return;
77
76
  }
@@ -49,19 +49,18 @@ exports.default = (0, createRule_1.createRule)({
49
49
  context.report({ node: statement, messageId: 'statement' });
50
50
  continue;
51
51
  }
52
- const { expression } = statement;
53
- if (isSuperCall(expression)) {
52
+ if (isSuperCall(statement.expression)) {
54
53
  continue;
55
54
  }
56
- if (expression.type !== utils_1.AST_NODE_TYPES.AssignmentExpression ||
57
- expression.operator !== '=' ||
58
- !isThisMember(expression.left)) {
55
+ if (statement.expression.type !== utils_1.AST_NODE_TYPES.AssignmentExpression ||
56
+ statement.expression.operator !== '=' ||
57
+ !isThisMember(statement.expression.left)) {
59
58
  context.report({ node: statement, messageId: 'statement' });
60
59
  continue;
61
60
  }
62
- if (!isPlainValue(expression.right)) {
61
+ if (!isPlainValue(statement.expression.right)) {
63
62
  context.report({
64
- node: expression.right,
63
+ node: statement.expression.right,
65
64
  messageId: 'computation',
66
65
  });
67
66
  }
@@ -18,10 +18,9 @@ exports.default = (0, createRule_1.createRule)({
18
18
  create(context) {
19
19
  return {
20
20
  ReturnStatement(node) {
21
- const { argument } = node;
22
- if (argument?.type === utils_1.AST_NODE_TYPES.Literal &&
23
- argument.value === null &&
24
- argument.raw === 'null') {
21
+ if (node.argument?.type === utils_1.AST_NODE_TYPES.Literal &&
22
+ node.argument.value === null &&
23
+ node.argument.raw === 'null') {
25
24
  context.report({ node, messageId: 'noNullReturn' });
26
25
  }
27
26
  },
@@ -2,6 +2,13 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const utils_1 = require("@typescript-eslint/utils");
4
4
  const createRule_1 = require("../utils/createRule");
5
+ /**
6
+ * A `null` sitting directly in a `return`. That shape is the domain of
7
+ * `no-null-return`, which reports it with its own message; reporting it here
8
+ * too would double up on one line of code.
9
+ */
10
+ const isDirectReturn = (node) => node.parent.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
11
+ node.parent.argument === node;
5
12
  exports.default = (0, createRule_1.createRule)({
6
13
  name: 'no-null',
7
14
  meta: {
@@ -21,10 +28,7 @@ exports.default = (0, createRule_1.createRule)({
21
28
  if (node.value !== null || node.raw !== 'null') {
22
29
  return;
23
30
  }
24
- // Direct `return null;` is the domain of `no-null-return`.
25
- const { parent } = node;
26
- if (parent.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
27
- parent.argument === node) {
31
+ if (isDirectReturn(node)) {
28
32
  return;
29
33
  }
30
34
  context.report({ node, messageId: 'noNull' });
@@ -0,0 +1,7 @@
1
+ type Options = [{
2
+ allowEnv: boolean;
3
+ }];
4
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"aliasesProperty", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
5
+ name: string;
6
+ };
7
+ export default _default;
@@ -0,0 +1,67 @@
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 locals_1 = require("../utils/locals");
6
+ const memberChain_1 = require("../utils/memberChain");
7
+ const DEFAULT_ALLOW_ENV = true;
8
+ /** Whether the chain reads `process.env.SOMETHING`. */
9
+ const isEnvRead = (node) => node.object.type === utils_1.AST_NODE_TYPES.MemberExpression &&
10
+ !node.object.computed &&
11
+ node.object.object.type === utils_1.AST_NODE_TYPES.Identifier &&
12
+ node.object.object.name === 'process' &&
13
+ node.object.property.type === utils_1.AST_NODE_TYPES.Identifier &&
14
+ node.object.property.name === 'env';
15
+ exports.default = (0, createRule_1.createRule)({
16
+ name: 'no-property-alias',
17
+ meta: {
18
+ type: 'suggestion',
19
+ docs: {
20
+ description: 'Disallow locals that only rename a property of an object already in hand. Ask the object where the value is needed instead.',
21
+ },
22
+ messages: {
23
+ aliasesProperty: "'{{name}}' only renames '{{path}}'. Ask the object where you need the value instead of copying its state into a local.",
24
+ },
25
+ schema: [
26
+ {
27
+ type: 'object',
28
+ properties: {
29
+ allowEnv: { type: 'boolean' },
30
+ },
31
+ additionalProperties: false,
32
+ },
33
+ ],
34
+ },
35
+ defaultOptions: [{ allowEnv: DEFAULT_ALLOW_ENV }],
36
+ create(context, [{ allowEnv }]) {
37
+ return {
38
+ VariableDeclarator(node) {
39
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
40
+ return;
41
+ }
42
+ if (node.init === null ||
43
+ node.init.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
44
+ !(0, memberChain_1.isObjectInHand)(node.init)) {
45
+ return;
46
+ }
47
+ if (allowEnv && isEnvRead(node.init)) {
48
+ return;
49
+ }
50
+ const [variable] = context.sourceCode.getDeclaredVariables(node);
51
+ if (variable === undefined ||
52
+ (0, locals_1.isReassigned)(variable) ||
53
+ (0, locals_1.escapesIntoFunction)(variable)) {
54
+ return;
55
+ }
56
+ context.report({
57
+ node,
58
+ messageId: 'aliasesProperty',
59
+ data: {
60
+ name: node.id.name,
61
+ path: context.sourceCode.getText(node.init),
62
+ },
63
+ });
64
+ },
65
+ };
66
+ },
67
+ });
@@ -0,0 +1,4 @@
1
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"destructuresObject", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
4
+ export default _default;
@@ -0,0 +1,59 @@
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 locals_1 = require("../utils/locals");
6
+ const memberChain_1 = require("../utils/memberChain");
7
+ /**
8
+ * Whether the pattern collects the properties it does not name into a rest
9
+ * object. That is construction by omission, not a copy of state anyone can
10
+ * replace with a member access.
11
+ */
12
+ const hasRestElement = (node) => node.properties.some((property) => property.type === utils_1.AST_NODE_TYPES.RestElement);
13
+ /**
14
+ * Whether any property carries a default. `options.max ?? 3` repeats the
15
+ * fallback at every use site, so the pattern is holding a decision rather than
16
+ * just a copy.
17
+ */
18
+ const hasDefault = (node) => node.properties.some((property) => property.type === utils_1.AST_NODE_TYPES.Property &&
19
+ property.value.type === utils_1.AST_NODE_TYPES.AssignmentPattern);
20
+ exports.default = (0, createRule_1.createRule)({
21
+ name: 'no-property-destructuring',
22
+ meta: {
23
+ type: 'suggestion',
24
+ docs: {
25
+ description: 'Disallow destructuring an object already in hand. Ask the object where each value is needed instead.',
26
+ },
27
+ messages: {
28
+ destructuresObject: "Destructuring '{{name}}' copies its state into {{count}} locals. Ask the object where you need each value instead.",
29
+ },
30
+ schema: [],
31
+ },
32
+ defaultOptions: [],
33
+ create(context) {
34
+ return {
35
+ ObjectPattern(node) {
36
+ if (node.parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
37
+ node.parent.init === null ||
38
+ !(0, memberChain_1.isObjectInHand)(node.parent.init)) {
39
+ return;
40
+ }
41
+ if (hasRestElement(node) || hasDefault(node)) {
42
+ return;
43
+ }
44
+ const variables = context.sourceCode.getDeclaredVariables(node.parent);
45
+ if (variables.some((variable) => (0, locals_1.isReassigned)(variable) || (0, locals_1.escapesIntoFunction)(variable))) {
46
+ return;
47
+ }
48
+ context.report({
49
+ node: node.parent,
50
+ messageId: 'destructuresObject',
51
+ data: {
52
+ name: context.sourceCode.getText(node.parent.init),
53
+ count: node.properties.length,
54
+ },
55
+ });
56
+ },
57
+ };
58
+ },
59
+ });
@@ -12,6 +12,12 @@ const keyName = (key) => {
12
12
  }
13
13
  return 'property';
14
14
  };
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.
19
+ */
20
+ const isPublicMutable = (node) => node.accessibility === 'public' && !node.readonly;
15
21
  exports.default = (0, createRule_1.createRule)({
16
22
  name: 'no-public-mutable-props',
17
23
  meta: {
@@ -38,9 +44,7 @@ exports.default = (0, createRule_1.createRule)({
38
44
  });
39
45
  },
40
46
  TSParameterProperty(node) {
41
- // Only constructor parameter properties carry an accessibility modifier.
42
- if (node.accessibility !== 'public' ||
43
- node.readonly) {
47
+ if (!isPublicMutable(node)) {
44
48
  return;
45
49
  }
46
50
  const target = node.parameter.type === utils_1.AST_NODE_TYPES.AssignmentPattern
@@ -0,0 +1,13 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ /**
3
+ * Whether anything writes to the variable after its declaration. Such a local
4
+ * holds mutable state that no member access stands in for, so it is not a copy
5
+ * of the object's state to begin with.
6
+ */
7
+ export declare const isReassigned: (variable: TSESLint.Scope.Variable) => boolean;
8
+ /**
9
+ * Whether the variable is read from inside a nested function. TypeScript drops
10
+ * a narrowing of `obj.prop` at the callback boundary but keeps it on a local,
11
+ * so such a declaration is load-bearing: inlining it stops compiling.
12
+ */
13
+ export declare const escapesIntoFunction: (variable: TSESLint.Scope.Variable) => boolean;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.escapesIntoFunction = exports.isReassigned = void 0;
4
+ /**
5
+ * Whether anything writes to the variable after its declaration. Such a local
6
+ * holds mutable state that no member access stands in for, so it is not a copy
7
+ * of the object's state to begin with.
8
+ */
9
+ const isReassigned = (variable) => variable.references.some((reference) => reference.isWrite() && !reference.init);
10
+ exports.isReassigned = isReassigned;
11
+ /**
12
+ * Whether the variable is read from inside a nested function. TypeScript drops
13
+ * a narrowing of `obj.prop` at the callback boundary but keeps it on a local,
14
+ * so such a declaration is load-bearing: inlining it stops compiling.
15
+ */
16
+ const escapesIntoFunction = (variable) => variable.references.some((reference) => {
17
+ let scope = reference.from;
18
+ while (scope !== null && scope !== variable.scope) {
19
+ if (scope.type === 'function') {
20
+ return true;
21
+ }
22
+ scope = scope.upper;
23
+ }
24
+ return false;
25
+ });
26
+ exports.escapesIntoFunction = escapesIntoFunction;
@@ -0,0 +1,8 @@
1
+ import { TSESTree } from '@typescript-eslint/utils';
2
+ /**
3
+ * Whether the expression denotes an object the reader already holds: a name,
4
+ * `this`, or a run of plain `.prop` accesses rooted at one of those. A computed
5
+ * link (`calls[1][0].metadata`) or a call in the middle
6
+ * (`resolveDates(query).startDate`) fails — nobody gains by inlining those.
7
+ */
8
+ export declare const isObjectInHand: (node: TSESTree.Node) => boolean;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isObjectInHand = void 0;
4
+ const utils_1 = require("@typescript-eslint/utils");
5
+ /**
6
+ * Whether the expression denotes an object the reader already holds: a name,
7
+ * `this`, or a run of plain `.prop` accesses rooted at one of those. A computed
8
+ * link (`calls[1][0].metadata`) or a call in the middle
9
+ * (`resolveDates(query).startDate`) fails — nobody gains by inlining those.
10
+ */
11
+ const isObjectInHand = (node) => {
12
+ let current = node;
13
+ while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
14
+ if (current.computed) {
15
+ return false;
16
+ }
17
+ current = current.object;
18
+ }
19
+ return (current.type === utils_1.AST_NODE_TYPES.Identifier ||
20
+ current.type === utils_1.AST_NODE_TYPES.ThisExpression);
21
+ };
22
+ exports.isObjectInHand = isObjectInHand;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianjos/eslint-plugin-elegant",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.json",
44
+ "lint": "npm run build && eslint .",
44
45
  "typecheck": "tsc -p tsconfig.test.json",
45
46
  "test": "jest",
46
47
  "release": "standard-version",