@tianjos/eslint-plugin-elegant 0.3.2 → 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.
@@ -0,0 +1,87 @@
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 OBJECTS = ['log', 'logger'];
6
+ const METHODS = [
7
+ 'debug',
8
+ 'error',
9
+ 'fatal',
10
+ 'info',
11
+ 'log',
12
+ 'trace',
13
+ 'verbose',
14
+ 'warn',
15
+ ];
16
+ /**
17
+ * The name a call hangs off, as written. `logger.info` yields `logger`, and
18
+ * `this.logger.info` yields `logger` too, so a field and a local read alike.
19
+ */
20
+ const receiver = (node) => {
21
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
22
+ return node.name;
23
+ }
24
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
25
+ !node.computed &&
26
+ node.property.type === utils_1.AST_NODE_TYPES.Identifier) {
27
+ return node.property.name;
28
+ }
29
+ return undefined;
30
+ };
31
+ /**
32
+ * The message is the first argument that is not an object literal, which lands
33
+ * on the message under either convention: `info(msg, data)` as in Nest and
34
+ * winston, and `info(data, msg)` as in pino.
35
+ */
36
+ const message = (args) => args.find((arg) => arg.type !== utils_1.AST_NODE_TYPES.ObjectExpression);
37
+ const computed = (node) => (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
38
+ node.expressions.length > 0) ||
39
+ (node.type === utils_1.AST_NODE_TYPES.BinaryExpression && node.operator === '+');
40
+ exports.default = (0, createRule_1.createRule)({
41
+ name: 'no-interpolated-log-message',
42
+ meta: {
43
+ type: 'suggestion',
44
+ docs: {
45
+ description: 'Require log messages to be constant, with the varying parts passed as structured data.',
46
+ },
47
+ messages: {
48
+ interpolatedMessage: 'A computed log message cannot be grouped or searched. Keep the message constant and pass the varying parts as structured data.',
49
+ },
50
+ schema: [
51
+ {
52
+ type: 'object',
53
+ properties: {
54
+ objects: { type: 'array', items: { type: 'string' } },
55
+ methods: { type: 'array', items: { type: 'string' } },
56
+ },
57
+ additionalProperties: false,
58
+ },
59
+ ],
60
+ },
61
+ defaultOptions: [{ objects: [], methods: [] }],
62
+ create(context, [{ objects, methods }]) {
63
+ const logging = new Set([...OBJECTS, ...objects]);
64
+ const levels = new Set([...METHODS, ...methods]);
65
+ return {
66
+ CallExpression(node) {
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)) {
71
+ return;
72
+ }
73
+ const name = receiver(node.callee.object);
74
+ if (name === undefined || !logging.has(name)) {
75
+ return;
76
+ }
77
+ const argument = message(node.arguments);
78
+ if (argument !== undefined && computed(argument)) {
79
+ context.report({
80
+ node: argument,
81
+ messageId: 'interpolatedMessage',
82
+ });
83
+ }
84
+ },
85
+ };
86
+ },
87
+ });
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@tianjos/eslint-plugin-elegant",
3
- "version": "0.3.2",
4
- "description": "Opinionated ESLint rules for elegant, behavior-rich TypeScript: no flag arguments, no type assertions, no null returns, no public mutable state, and small focused classes.",
3
+ "version": "0.5.0",
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",
7
7
  "eslint-plugin",
@@ -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",