@tianjos/eslint-plugin-elegant 0.4.0 → 0.6.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
@@ -84,12 +84,15 @@ The `recommended` config enables every custom rule plus two native ones,
84
84
  | `elegant/no-logic-in-constructor` | custom | Any constructor code beyond `this.field = value` stores and a `super(...)` call | `error` |
85
85
  | `elegant/no-getters-setters` | custom | `get`/`set` accessors (and `getX`/`setX` methods with `{ methods: true }`) | `error` |
86
86
  | `elegant/no-instanceof` | custom | Use of the `instanceof` operator | `error` |
87
- | `elegant/no-static-members` | custom | Static methods, properties, accessors, and blocks (`allowReadonly` to permit constants) | `error` |
87
+ | `elegant/no-static-members` | custom | Static methods, properties, accessors, and blocks (secondary constructors and Nest module factories excepted) | `error` |
88
88
  | `elegant/no-null` | custom | The `null` literal as a value (type annotations and direct `return null` excepted) | `error` |
89
89
  | `elegant/no-comments-in-function-body` | custom | Comments inside function bodies (directives and empty blocks excepted) | `error` |
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
 
@@ -209,8 +212,46 @@ the codebase.
209
212
  Static state and behavior cannot be injected, substituted, or mocked. Prefer
210
213
  instances (with dependency injection) and a module-level `const` for shared
211
214
  values. The `{ allowReadonly: true }` option permits `static readonly`
212
- constants. Note this also flags `static` factory methods (`static create()`),
213
- which are common; relax per-file if your design relies on them.
215
+ constants.
216
+
217
+ Two kinds of static are allowed by default, because neither is behaviour that
218
+ anyone would want to substitute.
219
+
220
+ **A secondary constructor** — a static whose declared return type is the class
221
+ itself. TypeScript cannot overload a constructor, so `static of(...): DueDate`
222
+ inside `DueDate` is the only way to write one, and calling it is
223
+ indistinguishable from calling `new`. That is what separates a named
224
+ constructor from a procedure that moved into a class:
225
+
226
+ ```ts
227
+ class DueDate {
228
+ static of(props: DueDateProps): DueDate {} // allowed
229
+ static parse(raw: unknown): DueDate {} // allowed
230
+ }
231
+
232
+ class DocumentFormatter {
233
+ static formatCNPJ(document: string): string {} // reported — a module function
234
+ }
235
+ ```
236
+
237
+ `this`, `Promise<Self>` and `Self | undefined` all count: a polymorphic, an
238
+ asynchronous and a failing constructor are still constructors. `Self | null`
239
+ does not, because `no-null-return` already owns that shape. The return type has
240
+ to be **written down** — the rule carries no type information, so an
241
+ unannotated `static create() { … }` stays reported. On a factory the annotation
242
+ is one word, and it is what makes the intent legible. Off via
243
+ `{ allowSelfReturning: false }`.
244
+
245
+ **A Nest module factory** — a static returning `DynamicModule` from a class
246
+ decorated with `@Module`. `forRoot`, `forRootAsync`, `register` and
247
+ `registerAsync` are mandated by the framework, not chosen by the design. Both
248
+ halves are required, so naming `DynamicModule` in a return type is not a way
249
+ out of the rule, and a module class gets no blanket exemption for its other
250
+ statics. Off via `{ allowModuleFactories: false }`.
251
+
252
+ Everything else still reports: static accessors (reading one is reaching for
253
+ static state, whatever it returns), `private static` helpers, and static
254
+ classes used as a namespace for functions.
214
255
 
215
256
  #### `no-null`
216
257
 
@@ -371,6 +412,148 @@ method's key, or the `const` or class field holding an arrow — falling back to
371
412
  `(anonymous)` for an inline callback. Configurable via `{ max: number }`
372
413
  (default `3`).
373
414
 
415
+ #### `no-property-alias`
416
+
417
+ A local whose whole job is to hold `obj.status` is a second name for state the
418
+ object already exposes under a name of its own. It buys nothing and it costs a
419
+ reader the hop of proving the two are the same value. Ask the object where you
420
+ need the answer.
421
+
422
+ ```ts
423
+ // reported
424
+ const objStatus = obj.status;
425
+ const authHeader = request.headers.authorization;
426
+ const region = this.cognitoRegion;
427
+
428
+ // passes
429
+ obj.status;
430
+ request.headers.authorization;
431
+ this.cognitoRegion;
432
+ ```
433
+
434
+ Only variable declarations are reported. `this.total = other.total` transfers
435
+ state rather than aliasing it, and a property in an object literal
436
+ (`{ id: dto.id }`) is how mappers are written; neither trips the rule.
437
+
438
+ Four shapes are never reported, because in each of them the local is doing real
439
+ work:
440
+
441
+ - **A reassigned local.** `let status = obj.status` followed by
442
+ `status = 'EXPIRED'` holds mutable state that no member access stands in for.
443
+ - **A local read inside a nested function.** TypeScript drops a narrowing of
444
+ `obj.prop` at the callback boundary but keeps it on a local, so inlining such
445
+ a declaration stops compiling:
446
+
447
+ ```ts
448
+ const status = obj.status;
449
+ if (status === undefined) return [];
450
+ return obj.items.map((n) => n + status.length); // needs the local
451
+ ```
452
+
453
+ - **A chain that is not a plain run of `.prop` accesses.** A computed link
454
+ (`repo.save.mock.calls[1][0].metadata`) or a call in the middle
455
+ (`resolveDates(query).startDate`) is not a property of an object in hand, and
456
+ repeating it reads worse than naming it.
457
+ - **An environment read.** `const topicArn = process.env.SNS_ERROR_TOPIC`
458
+ followed by a guard is fail-fast, and inlining it would read the environment
459
+ twice. Set `{ allowEnv: false }` to hold these to the same standard.
460
+
461
+ Its sibling `no-property-destructuring` covers the same reach-in written as a
462
+ pattern; together they say one thing, which is to ask the object.
463
+
464
+ This rule is the mirror image of ESLint's native
465
+ [`prefer-destructuring`](https://eslint.org/docs/latest/rules/prefer-destructuring),
466
+ which reports `const status = obj.status` and asks you to write
467
+ `const { status } = obj` instead. The two cannot both be on. `prefer-destructuring`
468
+ is off by default, so there is nothing to undo unless you enabled it — and note
469
+ that it only fires when the local and the property share a name, leaving the
470
+ renaming majority (`const objStatus = obj.status`) unreported either way.
471
+
472
+ #### `no-property-destructuring`
473
+
474
+ `const { status, enabled } = obj` is `no-property-alias` written as a pattern:
475
+ the object already names its own state, and the locals are a second set of
476
+ names for it. This rule covers the pattern form, and only when the thing being
477
+ destructured is an object you already hold — a name, `this`, or a run of plain
478
+ `.prop` accesses rooted at one of those.
479
+
480
+ ```ts
481
+ // reported
482
+ const { status, enabled } = obj;
483
+ const { access_token, expires_in } = response.data;
484
+ const { region, poolId } = this.config;
485
+
486
+ // passes — none of these was an object in hand
487
+ function create({ id, name }) {}
488
+ for (const { id, total } of rows) {}
489
+ const { csvContent } = await service.exportCsv(query);
490
+ const { startDate } = resolveDates(query);
491
+ const [rows, total] = await repo.findAndCount();
492
+ ```
493
+
494
+ Parameter patterns, loop bindings, and `catch` bindings are how you receive a
495
+ value rather than reach into one, so they never come up. Neither does
496
+ `ArrayPattern`: `const [rows, total] = ...` names the halves of a tuple that
497
+ carries no names of its own.
498
+
499
+ Four shapes are never reported, because in each of them the pattern is doing
500
+ work no member access does:
501
+
502
+ - **A rest element.** `const { authorization: _auth, ...safe } = headers`
503
+ constructs a new object by omission. There is nothing to inline it into.
504
+ - **A default value.** `const { max = 3 } = options` inlines to
505
+ `options.max ?? 3`, repeating the fallback at every use site.
506
+ - **A local read inside a nested function**, for the narrowing reason spelled
507
+ out under `no-property-alias`.
508
+ - **A local reassigned later.** `let { status } = obj` followed by
509
+ `status = 'EXPIRED'` holds mutable state of its own.
510
+
511
+ Renaming on the way out (`const { ingestion: failure } = row`) is still
512
+ copying, and so is a single property. Width makes no difference: a pattern
513
+ pulling four fields off an `input` is usually the sign that the method wanted
514
+ the object, not the fields.
515
+
516
+ #### `no-anonymous-param-type`
517
+
518
+ `max-params` and `no-boolean-param` both push you towards an options object —
519
+ and an options object typed inline is a bag that got away with it. The
520
+ parameter count went down, the coupling did not, and the shape has nowhere to
521
+ grow behaviour. Give it a name and it can become a value object; leave it
522
+ anonymous and it stays a struct.
523
+
524
+ ```ts
525
+ // reported
526
+ private toResponse(group: { id: string; name: string; members: number }) {}
527
+ async createFundingProducts(data: { originCode: string; productId: string }) {}
528
+ chart(rows: Array<{ day: string; count: string }>) {}
529
+ constructor(private readonly config: { host: string; port: number }) {}
530
+
531
+ // passes
532
+ async register(input: RegisterProposal) {}
533
+ function charge(amount: number, currency: string) {}
534
+ ingest(raw: Record<string, unknown>) {}
535
+ ```
536
+
537
+ A shape counts wherever it hides in the annotation — on its own, in a union
538
+ with `null`, intersected onto a named type, in an array, or inside a generic
539
+ argument such as `Array<{ … }>`. A parameter is reported once however many
540
+ shapes it holds, and each offending parameter is reported separately.
541
+ Destructuring in the signature (`function create({ id }: { id: string })`)
542
+ does not hide the bag, and neither does a default value.
543
+
544
+ **Inline callbacks are never reported.** `res.body.items.map((i: { ccbNumber: string; total: number }) => i.total)`
545
+ annotates whatever the callee yields; when that value has no type to borrow, an
546
+ inline shape is the only way to type it at all.
547
+
548
+ Configurable via `{ minMembers: number }` (default `2`). At the default, a
549
+ one-property parameter like `opts?: { required?: boolean }` passes — naming a
550
+ single field is usually ceremony rather than design. Set `minMembers: 1` to
551
+ hold those to the same standard.
552
+
553
+ Unlike its neighbours, this rule does not have a mechanical fix: it asks you to
554
+ introduce a named type and decide where it lives. That is a design change, so
555
+ expect adoption to cost more than a find-and-replace.
556
+
374
557
  ## Configuration
375
558
 
376
559
  ### Overriding thresholds
package/dist/index.d.ts CHANGED
@@ -42,6 +42,8 @@ declare const rules: {
42
42
  };
43
43
  'no-static-members': TSESLint.RuleModule<"staticMember", [{
44
44
  allowReadonly: boolean;
45
+ allowSelfReturning: boolean;
46
+ allowModuleFactories: boolean;
45
47
  }], unknown, TSESLint.RuleListener> & {
46
48
  name: string;
47
49
  };
@@ -67,6 +69,19 @@ declare const rules: {
67
69
  }], unknown, TSESLint.RuleListener> & {
68
70
  name: string;
69
71
  };
72
+ 'no-property-alias': TSESLint.RuleModule<"aliasesProperty", [{
73
+ allowEnv: boolean;
74
+ }], unknown, TSESLint.RuleListener> & {
75
+ name: string;
76
+ };
77
+ 'no-property-destructuring': TSESLint.RuleModule<"destructuresObject", [], unknown, TSESLint.RuleListener> & {
78
+ name: string;
79
+ };
80
+ 'no-anonymous-param-type': TSESLint.RuleModule<"anonymousParamType", [{
81
+ minMembers: number;
82
+ }], unknown, TSESLint.RuleListener> & {
83
+ name: string;
84
+ };
70
85
  };
71
86
  type Plugin = {
72
87
  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
@@ -1,6 +1,9 @@
1
- type Options = [{
1
+ type Allowances = {
2
2
  allowReadonly: boolean;
3
- }];
3
+ allowSelfReturning: boolean;
4
+ allowModuleFactories: boolean;
5
+ };
6
+ type Options = [Allowances];
4
7
  declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"staticMember", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
5
8
  name: string;
6
9
  };
@@ -1,6 +1,72 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@typescript-eslint/utils");
3
4
  const createRule_1 = require("../utils/createRule");
5
+ /**
6
+ * Whether the annotation denotes the class itself. TypeScript has no secondary
7
+ * constructors, so `static of(...): DueDate` inside `DueDate` is the only way
8
+ * to write one — and calling it is indistinguishable from calling `new`, which
9
+ * is what separates a named constructor from a procedure that moved into a
10
+ * class.
11
+ *
12
+ * `this`, `Promise<Self>` and `Self | undefined` all count: a polymorphic, an
13
+ * asynchronous and a failing constructor are still constructors. `Self | null`
14
+ * deliberately does not, because `no-null-return` already owns that shape.
15
+ */
16
+ const isNamed = (node, name) => node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
17
+ node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
18
+ node.typeName.name === name;
19
+ /** What a `Promise<T>` resolves to, or the node itself. */
20
+ const awaited = (node) => isNamed(node, 'Promise') && node.type === utils_1.AST_NODE_TYPES.TSTypeReference
21
+ ? (node.typeArguments?.params[0] ?? node)
22
+ : node;
23
+ const isSelf = (node, className) => {
24
+ const resolved = awaited(node);
25
+ if (resolved.type === utils_1.AST_NODE_TYPES.TSThisType) {
26
+ return true;
27
+ }
28
+ if (resolved.type === utils_1.AST_NODE_TYPES.TSUnionType) {
29
+ return (resolved.types.some((member) => isSelf(member, className)) &&
30
+ resolved.types.every((member) => isSelf(member, className) ||
31
+ member.type === utils_1.AST_NODE_TYPES.TSUndefinedKeyword));
32
+ }
33
+ return isNamed(resolved, className);
34
+ };
35
+ /**
36
+ * A Nest module factory: `DynamicModule` returned from a class Nest recognises
37
+ * as a module. Both halves are required, so `DynamicModule` cannot become a
38
+ * general escape from the rule by being named in a return type.
39
+ */
40
+ const isModuleFactory = (method) => {
41
+ const annotation = method.value.returnType?.typeAnnotation;
42
+ if (annotation === undefined || !isNamed(awaited(annotation), 'DynamicModule')) {
43
+ return false;
44
+ }
45
+ return method.parent.parent.decorators.some((decorator) => decorator.expression.type === utils_1.AST_NODE_TYPES.CallExpression
46
+ ? isModuleIdentifier(decorator.expression.callee)
47
+ : isModuleIdentifier(decorator.expression));
48
+ };
49
+ const isModuleIdentifier = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === 'Module';
50
+ /**
51
+ * A secondary constructor's return, read from the annotation alone. The rule
52
+ * carries no type information, so an unannotated static stays reported: on a
53
+ * factory the annotation is one word, and it is what makes the intent legible.
54
+ */
55
+ const returnsSelf = (method, className) => {
56
+ const annotation = method.value.returnType?.typeAnnotation;
57
+ return (className !== undefined &&
58
+ annotation !== undefined &&
59
+ isSelf(annotation, className));
60
+ };
61
+ /**
62
+ * A static the rule lets through: a secondary constructor, or a module factory
63
+ * the framework demands. An accessor is neither, whatever it returns — reading
64
+ * one is reaching for static state.
65
+ */
66
+ const isPermitted = (node, allow) => node.kind === 'method' &&
67
+ ((allow.allowSelfReturning &&
68
+ returnsSelf(node, node.parent.parent.id?.name)) ||
69
+ (allow.allowModuleFactories && isModuleFactory(node)));
4
70
  exports.default = (0, createRule_1.createRule)({
5
71
  name: 'no-static-members',
6
72
  meta: {
@@ -14,24 +80,34 @@ exports.default = (0, createRule_1.createRule)({
14
80
  schema: [
15
81
  {
16
82
  type: 'object',
17
- properties: { allowReadonly: { type: 'boolean' } },
83
+ properties: {
84
+ allowReadonly: { type: 'boolean' },
85
+ allowSelfReturning: { type: 'boolean' },
86
+ allowModuleFactories: { type: 'boolean' },
87
+ },
18
88
  additionalProperties: false,
19
89
  },
20
90
  ],
21
91
  },
22
- defaultOptions: [{ allowReadonly: false }],
23
- create(context, [{ allowReadonly }]) {
92
+ defaultOptions: [
93
+ {
94
+ allowReadonly: false,
95
+ allowSelfReturning: true,
96
+ allowModuleFactories: true,
97
+ },
98
+ ],
99
+ create(context, [allow]) {
24
100
  const reportKey = (key) => {
25
101
  context.report({ node: key, messageId: 'staticMember' });
26
102
  };
27
103
  return {
28
104
  MethodDefinition(node) {
29
- if (node.static) {
105
+ if (node.static && !isPermitted(node, allow)) {
30
106
  reportKey(node.key);
31
107
  }
32
108
  },
33
109
  PropertyDefinition(node) {
34
- if (node.static && !(allowReadonly && node.readonly)) {
110
+ if (node.static && !(allow.allowReadonly && node.readonly)) {
35
111
  reportKey(node.key);
36
112
  }
37
113
  },
@@ -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.6.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,9 +41,11 @@
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
- "release": "standard-version",
47
+ "release": "standard-version --release-as minor",
48
+ "release:patch": "standard-version --release-as patch",
47
49
  "prepublishOnly": "npm run build && npm test"
48
50
  },
49
51
  "dependencies": {