@tianjos/eslint-plugin-elegant 0.9.0 → 0.10.1

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
@@ -68,13 +68,17 @@ Adopting this on a codebase that already exists? Spread
68
68
  so the first run gives you a list you can work through. See
69
69
  [Adopting on an existing codebase](#adopting-on-an-existing-codebase).
70
70
 
71
- A complete, copy-pasteable example (including test-file overrides) lives in
71
+ Two more configs exist for the files a preset should not judge the same way —
72
+ `tests` and `off`. See [Relaxing rules in test files](#relaxing-rules-in-test-files)
73
+ and [Generated and scaffolded files](#generated-and-scaffolded-files).
74
+
75
+ A complete, copy-pasteable example (including those overrides) lives in
72
76
  [`eslint.config.example.mjs`](./eslint.config.example.mjs).
73
77
 
74
78
  ## Rules
75
79
 
76
- The plugin exports two configs, both carrying every rule below plus two native
77
- ones, [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
80
+ The plugin exports four configs. Two are presets — `recommended` and `starter`
81
+ — carrying every rule below plus two native ones, [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
78
82
  [`no-else-return`](https://eslint.org/docs/latest/rules/no-else-return):
79
83
 
80
84
  - **`recommended`** — the severities in the table below. What the plugin
@@ -82,6 +86,10 @@ ones, [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
82
86
  - **`starter`** — the same rules with the four heaviest demoted, for adopting
83
87
  on a codebase that already exists. See
84
88
  [Adopting on an existing codebase](#adopting-on-an-existing-codebase).
89
+ - **`tests`** — an override, not a preset: the eight rules a spec legitimately
90
+ trips, off. See [Relaxing rules in test files](#relaxing-rules-in-test-files).
91
+ - **`off`** — an override too: every rule disabled, for generated files. See
92
+ [Generated and scaffolded files](#generated-and-scaffolded-files).
85
93
 
86
94
  | Rule | Source | What it catches | `recommended` |
87
95
  | -------------------------------------- | ------ | ------------------------------------------------------------------------------- | ------------- |
@@ -89,7 +97,8 @@ ones, [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
89
97
  | `elegant/max-class-methods` | custom | Classes with more methods than the configured `max` (constructors excluded) | `warn` (max 10) |
90
98
  | `elegant/max-class-dependencies` | custom | Classes depending on more distinct collaborators than `max` (constructor injections plus `new`) | `warn` (max 4) |
91
99
  | `elegant/max-class-fields` | custom | Classes holding more instance fields than `max` (declared fields plus parameter properties) | `warn` (max 5) |
92
- | `elegant/no-type-assertion` | custom | `value as T` and `<T>value` assertions (`as const` is allowed) | `error` |
100
+ | `elegant/no-type-assertion` | custom | `value as T`, `<T>value`, and `value!` assertions (`as const` is allowed) | `error` |
101
+ | `elegant/no-any-return` | custom | `any` (or `Promise<any>`) declared as a function's return type | `error` |
93
102
  | `elegant/no-null-return` | custom | `return null` statements | `error` |
94
103
  | `elegant/no-public-mutable-props` | custom | Public, non-`readonly` class properties and public constructor parameter props | `error` |
95
104
  | `elegant/no-logic-in-constructor` | custom | Any constructor code beyond `this.field = value` stores and a `super(...)` call | `error` |
@@ -189,10 +198,74 @@ Assertions silence the type checker. Reach for a type guard, a generic, or a
189
198
  correctly typed value instead. `as const` is permitted because it narrows rather
190
199
  than widens.
191
200
 
201
+ All three syntactic forms are the same act, so all three are reported: `value
202
+ as T`, `<T>value`, and the non-null operator `value!`. The last one is the one
203
+ worth naming, because it is the cheapest to type and the most expensive to be
204
+ wrong about — `entity.rate!` compiles whether the column is nullable, whether
205
+ the driver hands back a string, or whether the row simply has no value. Narrow
206
+ it with a check that throws, or correct the type if it was never nullable:
207
+
208
+ ```ts
209
+ // reported
210
+ const rate = origin.subsequentRate!;
211
+
212
+ // intended
213
+ const requireRate = (origin: Origin): number => {
214
+ if (origin.subsequentRate === undefined) {
215
+ throw new MissingRateError(origin.code);
216
+ }
217
+ return origin.subsequentRate;
218
+ };
219
+ ```
220
+
221
+ Pairs with [`no-any-return`](#no-any-return), which closes the way around it.
222
+
223
+ #### `no-any-return`
224
+
225
+ A function whose declared return type is `any` widens every value that passes
226
+ through it. That is a type assertion — the caller writes `const body: T =
227
+ parse(raw)` and the checker agrees — except it is invisible: `as T` is
228
+ greppable at the call site, an `any` return is not.
229
+
230
+ This is the shape `no-type-assertion` pushes code into if nothing catches it.
231
+ The cast does not disappear; it moves one call deeper and stops being reviewable.
232
+
233
+ ```ts
234
+ // reported — every caller's type is asserted for them
235
+ const readJson = async (response: Response): Promise<any> => response.json();
236
+
237
+ // intended — the caller narrows, or supplies the type it is claiming
238
+ const readJson = async (response: Response): Promise<unknown> => response.json();
239
+ const request = async <T>(path: string): Promise<T> => fetch(path).then(parse);
240
+ ```
241
+
242
+ Return position only. `any` on a *parameter* is a different (lesser) defect and
243
+ belongs to [`@typescript-eslint/no-explicit-any`](https://typescript-eslint.io/rules/no-explicit-any);
244
+ this rule stays narrow so it can ship in the preset without requiring
245
+ type-aware linting. `Promise<any>` counts, because awaiting it is not a
246
+ narrowing step.
247
+
192
248
  #### `no-null-return`
193
249
 
194
- Keeps absence out of return values; model it with an explicit domain type or
195
- throw.
250
+ Keeps absence out of return values. Throw when the value must exist, or return
251
+ an object that answers for the absent case — a null object, a domain type with
252
+ a "nothing found" state.
253
+
254
+ An empty collection models absence only where the return type *was already* a
255
+ collection. Wrapping a single value in a zero-or-one array to dodge this rule
256
+ is a null in a box: the type now promises a list it will never have more than
257
+ one of, and every caller loops over something that is really an `if`.
258
+
259
+ ```ts
260
+ // reported
261
+ function decide(status: number): Retry | null { ... }
262
+
263
+ // a null in a box — the type lies, and callers write a loop that runs once
264
+ function decide(status: number): Retry[] { ... }
265
+
266
+ // intended
267
+ function decide(status: number): Retry { return matched ?? Retry.none(); }
268
+ ```
196
269
 
197
270
  #### `no-public-mutable-props`
198
271
 
@@ -242,6 +315,49 @@ predictable. Parameter properties (`constructor(private readonly x: T)`) and a
242
315
  leading `super(...)` are allowed; computed right-hand sides (`this.x = x * 2`,
243
316
  `this.items = items.slice()`) and any non-assignment statement are flagged.
244
317
 
318
+ **On a class a DI container builds**, the remedy the rule names does not exist:
319
+ nobody calls `new` on a Nest provider, so there is no static factory to move
320
+ the work to. The tempting move is to push it into a lifecycle hook, and that
321
+ trades one rule for a worse invariant — the field stops being `readonly` and
322
+ starts being assigned some time after construction:
323
+
324
+ ```ts
325
+ // reported
326
+ constructor(private readonly config: ConfigService) {
327
+ this.baseUrl = this.config.getOrThrow('COBRANSAAS_BASE_URL');
328
+ }
329
+
330
+ // worse: the field is now mutable and empty until a hook runs
331
+ private baseUrl: string;
332
+ onModuleInit() {
333
+ this.baseUrl = this.config.getOrThrow('COBRANSAAS_BASE_URL');
334
+ }
335
+ ```
336
+
337
+ Resolve the config where the module is wired, and inject the result. The
338
+ constructor goes back to storing an argument, the field stays `readonly`, and a
339
+ missing variable fails at boot instead of on the first request:
340
+
341
+ ```ts
342
+ // cobransaas.module.ts
343
+ providers: [
344
+ {
345
+ provide: COBRANSAAS_SETTINGS,
346
+ inject: [ConfigService],
347
+ useFactory: (config: ConfigService): CobransaasSettings => ({
348
+ baseUrl: config.getOrThrow('COBRANSAAS_BASE_URL'),
349
+ clientId: config.getOrThrow('COBRANSAAS_CLIENT_ID'),
350
+ }),
351
+ },
352
+ ]
353
+
354
+ // cobransaas-http-client.service.ts
355
+ constructor(
356
+ @Inject(COBRANSAAS_SETTINGS)
357
+ private readonly settings: CobransaasSettings,
358
+ ) {}
359
+ ```
360
+
245
361
  #### `no-getters-setters`
246
362
 
247
363
  Getters and setters turn objects into data bags; prefer methods that expose
@@ -256,7 +372,7 @@ around repositories and framework hooks, so it stays off in `recommended`.
256
372
  the object. Pairs with `no-type-assertion` to keep type-based branching out of
257
373
  the codebase.
258
374
 
259
- Two uses are allowed by default, because in both of them TypeScript leaves no
375
+ Three uses are allowed by default, because in each of them TypeScript leaves no
260
376
  polymorphic alternative to reach for.
261
377
 
262
378
  **A self-guard** — `other instanceof Money` inside `class Money`. Value
@@ -272,6 +388,22 @@ method on the value can stand in, because at that point the value has no known
272
388
  methods. Resolved through the scope chain, so the narrowing still counts one
273
389
  closure deeper. Off via `{ allowCaughtValues: false }`.
274
390
 
391
+ **A declared type guard** — a function whose return type is a predicate,
392
+ `value is X`. Some classes are nominal and offer no discriminant to switch on:
393
+ a framework exception, a value object from another module, an `Error` subclass.
394
+ The check has to happen somewhere, and a `value is X` signature is the one
395
+ place it states what it is doing — the answer leaves as a narrowed type instead
396
+ of a bare boolean, the class name is written once, and the project ends up with
397
+ one greppable guard per class rather than an `instanceof` in the middle of a
398
+ method. Only the innermost enclosing function counts, so a guard cannot lend
399
+ its exemption to the code that follows it. Off via `{ allowTypeGuards: false }`.
400
+
401
+ This exists so the cheapest way out of the rule is also the honest one. Without
402
+ it, the reachable workaround is structural duck typing — `'toDate' in value`
403
+ instead of `value instanceof IsoDate` — which passes the linter, passes for any
404
+ object that happens to carry the member, and is strictly worse than what it
405
+ replaced.
406
+
275
407
  ```ts
276
408
  // allowed
277
409
  class Money {
@@ -282,12 +414,19 @@ class Money {
282
414
  try { charge(); } catch (error) {
283
415
  if (error instanceof HttpException) { log(error.getStatus()); }
284
416
  }
417
+ export const isIsoDate = (value: unknown): value is IsoDate =>
418
+ value instanceof IsoDate;
285
419
 
286
420
  // still reported
287
421
  if (shape instanceof Circle) { draw(); }
288
422
  function handle(error: HttpException) { return error instanceof HttpException; }
423
+ function isIsoDate(value: unknown): boolean { return value instanceof IsoDate; }
289
424
  ```
290
425
 
426
+ The last one is the near miss worth spelling out: a function that returns
427
+ `boolean` declares nothing. It is a guard only once the signature says
428
+ `value is IsoDate`.
429
+
291
430
  An error that arrives as a plain parameter rather than through `catch` — Nest's
292
431
  `ExceptionFilter.catch(exception, host)`, an RxJS `catchError` callback — is
293
432
  **not** covered, because a parameter's type is whatever the signature says and
@@ -807,6 +946,16 @@ rules: {
807
946
  }
808
947
  ```
809
948
 
949
+ Two rules arrived after that measurement and are not in the table above:
950
+ `no-any-return`, and `no-type-assertion`'s coverage of the non-null operator
951
+ `x!`. Measured separately over a fourth service — 135 production files, same
952
+ shape — they are tail rules, not migrations: **2** reports for `x!` and **0**
953
+ for `no-any-return`. The interesting number is from the same repository *after*
954
+ a full pass to green under `starter`: the tree linted clean, and the two rules
955
+ still found one `Promise<any>` return that had absorbed a cast the pass had
956
+ removed. They are cheap to adopt and they close a door the other rules push
957
+ people through.
958
+
810
959
  Numbers from one corpus are indicative, not universal. Run
811
960
  `npx eslint . --format json` on your own and sort by rule before deciding
812
961
  anything — the shape of your code decides which of these rules is a signal and
@@ -814,30 +963,62 @@ which is a migration.
814
963
 
815
964
  ### Relaxing rules in test files
816
965
 
817
- Tests routinely use flag arguments and larger fixtures. Add a second config
818
- block scoped to your spec globs:
966
+ Spread `tests` in a config block scoped to your spec globs:
819
967
 
820
968
  ```js
821
969
  {
822
970
  files: ['**/*.spec.ts', '**/*.test.ts', '**/*.e2e-spec.ts'],
823
- rules: {
824
- 'elegant/no-boolean-param': 'off',
825
- 'elegant/max-class-methods': 'off',
826
- 'elegant/max-class-dependencies': 'off',
827
- 'elegant/max-class-fields': 'off',
828
- 'elegant/no-comments-in-function-body': 'off',
829
- 'max-params': 'off',
830
- },
971
+ rules: { ...elegant.configs.tests.rules },
972
+ }
973
+ ```
974
+
975
+ It turns off eight rules, and the list is a measurement rather than a taste.
976
+ Over the corpus above, these are the rules that actually report inside test
977
+ files, each for a reason that holds there and nowhere else:
978
+
979
+ | Rule | Reports in tests | Why it holds in a spec |
980
+ | --- | ---: | --- |
981
+ | `no-comments-in-function-body` | 2,589 | a spec narrates the scenario |
982
+ | `no-type-assertion` | 935 | a mock asserts a type over a partial object |
983
+ | `no-null` | 896 | a fixture mirrors a nullable column |
984
+ | `no-anonymous-param-type` | 27 | a fixture builder takes an inline shape |
985
+ | `no-generic-error` | 17 | `throw new Error('boom')` as a failure stub |
986
+ | `max-params` | 6 | a setup helper |
987
+ | `no-null-return` | 2 | a fixture returns absence |
988
+ | `no-boolean-param` | 1 | `make*(withRefunds: true)` names the case under test |
989
+
990
+ What the list leaves out is deliberate. `max-class-fields`, `max-returns`,
991
+ `no-static-members`, `no-interpolated-log-message` and the other class-shape
992
+ rules report **zero** times in specs on that corpus, so switching them off buys
993
+ nothing today and costs you the report on the day a spec finally earns one.
994
+ Turn a rule off when you have seen it fire and disagreed — not in advance.
995
+
996
+ ### Generated and scaffolded files
997
+
998
+ Some files are not written by hand: a migration the TypeORM CLI emits, a script
999
+ that generates an OpenAPI document and talks to an operator through `console`.
1000
+ Judging them by rules meant for domain code produces churn in files nobody
1001
+ should reopen. Spread `off`, which is every rule this plugin ships, disabled:
1002
+
1003
+ ```js
1004
+ {
1005
+ files: ['src/database/migrations/**/*.ts', 'utils/**/*.ts'],
1006
+ rules: { ...elegant.configs.off.rules },
831
1007
  }
832
1008
  ```
833
1009
 
1010
+ Derived from the plugin's own rule list rather than spelled out in your config,
1011
+ so a rule added in a later version arrives already silent in those files. A
1012
+ hand-rolled equivalent — mapping over `Object.keys(elegant.rules)` in your own
1013
+ config — goes stale the moment it is written.
1014
+
834
1015
  ## Compatibility
835
1016
 
836
1017
  The package ships a single CommonJS build that is consumable as both
837
1018
  `require('@tianjos/eslint-plugin-elegant')` and an ESM
838
1019
  `import elegant from '@tianjos/eslint-plugin-elegant'`. The exported object
839
- exposes `{ meta, rules, configs }`, where `configs` holds `recommended` and
840
- `starter`. All three load paths are exercised against the built output by
1020
+ exposes `{ meta, rules, configs }`, where `configs` holds `recommended`,
1021
+ `starter`, `tests`, and `off`. All three load paths are exercised against the built output by
841
1022
  `tests/dist.test.ts`.
842
1023
 
843
1024
  ## Prior art
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { TSESLint } from '@typescript-eslint/utils';
2
2
  declare const rules: {
3
+ 'no-any-return': TSESLint.RuleModule<"anyReturn", [], unknown, TSESLint.RuleListener> & {
4
+ name: string;
5
+ };
3
6
  'no-boolean-param': TSESLint.RuleModule<"booleanParam", [], unknown, TSESLint.RuleListener> & {
4
7
  name: string;
5
8
  };
@@ -20,7 +23,7 @@ declare const rules: {
20
23
  }], unknown, TSESLint.RuleListener> & {
21
24
  name: string;
22
25
  };
23
- 'no-type-assertion': TSESLint.RuleModule<"noAssertion", [], unknown, TSESLint.RuleListener> & {
26
+ 'no-type-assertion': TSESLint.RuleModule<"noAssertion" | "nonNullAssertion", [], unknown, TSESLint.RuleListener> & {
24
27
  name: string;
25
28
  };
26
29
  'no-null-return': TSESLint.RuleModule<"noNullReturn", [], unknown, TSESLint.RuleListener> & {
@@ -43,6 +46,7 @@ declare const rules: {
43
46
  'no-instanceof': TSESLint.RuleModule<"noInstanceof", [{
44
47
  allowSelfGuard: boolean;
45
48
  allowCaughtValues: boolean;
49
+ allowTypeGuards: boolean;
46
50
  }], unknown, TSESLint.RuleListener> & {
47
51
  name: string;
48
52
  };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  const no_anonymous_param_type_1 = __importDefault(require("./rules/no-anonymous-param-type"));
6
+ const no_any_return_1 = __importDefault(require("./rules/no-any-return"));
6
7
  const max_class_dependencies_1 = __importDefault(require("./rules/max-class-dependencies"));
7
8
  const max_method_lines_1 = __importDefault(require("./rules/max-method-lines"));
8
9
  const max_class_fields_1 = __importDefault(require("./rules/max-class-fields"));
@@ -30,6 +31,7 @@ const no_type_assertion_1 = __importDefault(require("./rules/no-type-assertion")
30
31
  // eslint-disable-next-line elegant/no-type-assertion
31
32
  const { name, version } = require('../package.json');
32
33
  const rules = {
34
+ 'no-any-return': no_any_return_1.default,
33
35
  'no-boolean-param': no_boolean_param_1.default,
34
36
  'max-class-methods': max_class_methods_1.default,
35
37
  'max-class-dependencies': max_class_dependencies_1.default,
@@ -62,6 +64,7 @@ plugin.configs.recommended = {
62
64
  name: 'elegant/recommended',
63
65
  plugins: { elegant: plugin },
64
66
  rules: {
67
+ 'elegant/no-any-return': 'error',
65
68
  'elegant/no-boolean-param': 'error',
66
69
  'elegant/max-class-methods': ['warn', { max: 10 }],
67
70
  'elegant/max-class-dependencies': ['warn', { max: 4 }],
@@ -115,5 +118,46 @@ plugin.configs.starter = {
115
118
  ...NOISIEST,
116
119
  },
117
120
  };
121
+ /**
122
+ * Every rule this plugin ships, off. For files nobody writes by hand: a
123
+ * migration the TypeORM CLI scaffolds, a build script that talks to an
124
+ * operator through `console`. Derived from `rules` here rather than listed in
125
+ * the consumer's config, so a rule added in a later version arrives already
126
+ * silent in those files instead of reporting on generated code.
127
+ */
128
+ plugin.configs.off = {
129
+ name: 'elegant/off',
130
+ plugins: { elegant: plugin },
131
+ rules: Object.fromEntries(Object.keys(rules).map((rule) => [`elegant/${rule}`, 'off'])),
132
+ };
133
+ /**
134
+ * The rules a spec legitimately trips, off — and only those.
135
+ *
136
+ * Which ones those are is a measurement, not a taste: over the corpus in
137
+ * "Adopting on an existing codebase", these eight are the rules that fire
138
+ * inside test files, each for a reason that holds there and nowhere else. A
139
+ * mock has to assert a type over a partial object, a fixture mirrors a
140
+ * nullable column, `make*(withRefunds: true)` names the scenario under test,
141
+ * and a spec narrates.
142
+ *
143
+ * The rules kept out of this list are kept out on purpose. `max-class-fields`,
144
+ * `max-returns`, `no-static-members` and the rest report zero times in specs
145
+ * on that corpus, so turning them off buys nothing and costs the report on the
146
+ * day a spec finally earns one.
147
+ */
148
+ plugin.configs.tests = {
149
+ name: 'elegant/tests',
150
+ plugins: { elegant: plugin },
151
+ rules: {
152
+ 'elegant/no-comments-in-function-body': 'off',
153
+ 'elegant/no-type-assertion': 'off',
154
+ 'elegant/no-null': 'off',
155
+ 'elegant/no-null-return': 'off',
156
+ 'elegant/no-generic-error': 'off',
157
+ 'elegant/no-boolean-param': 'off',
158
+ 'elegant/no-anonymous-param-type': 'off',
159
+ 'max-params': 'off',
160
+ },
161
+ };
118
162
  plugin.default = plugin;
119
163
  module.exports = plugin;
@@ -0,0 +1,4 @@
1
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"anyReturn", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
4
+ export default _default;
@@ -0,0 +1,48 @@
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 isNamed = (node, name) => node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
6
+ node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
7
+ node.typeName.name === name;
8
+ /**
9
+ * What a `Promise<T>` resolves to, or the node itself. `Promise<any>` is the
10
+ * same promise to the caller as `any` — awaiting it is not a narrowing step.
11
+ */
12
+ const awaited = (node) => isNamed(node, 'Promise') && node.type === utils_1.AST_NODE_TYPES.TSTypeReference
13
+ ? (node.typeArguments?.params[0] ?? node)
14
+ : node;
15
+ exports.default = (0, createRule_1.createRule)({
16
+ name: 'no-any-return',
17
+ meta: {
18
+ type: 'suggestion',
19
+ docs: {
20
+ description: 'Disallow `any` as a return type. A function that returns `any` widens every value that passes through it, which is a type assertion the reader cannot see.',
21
+ },
22
+ messages: {
23
+ anyReturn: 'Returning `any` asserts every caller\'s type for them, invisibly. Return `unknown` and make the caller narrow, or a generic the caller supplies.',
24
+ },
25
+ schema: [],
26
+ },
27
+ defaultOptions: [],
28
+ create(context) {
29
+ const check = (node) => {
30
+ const annotation = node.returnType?.typeAnnotation;
31
+ if (annotation !== undefined &&
32
+ awaited(annotation).type === utils_1.AST_NODE_TYPES.TSAnyKeyword) {
33
+ context.report({ node: annotation, messageId: 'anyReturn' });
34
+ }
35
+ };
36
+ return {
37
+ ArrowFunctionExpression: check,
38
+ FunctionDeclaration: check,
39
+ FunctionExpression: check,
40
+ TSCallSignatureDeclaration: check,
41
+ TSConstructSignatureDeclaration: check,
42
+ TSDeclareFunction: check,
43
+ TSEmptyBodyFunctionExpression: check,
44
+ TSFunctionType: check,
45
+ TSMethodSignature: check,
46
+ };
47
+ },
48
+ });
@@ -1,7 +1,10 @@
1
- type Options = [{
2
- allowSelfGuard: boolean;
3
- allowCaughtValues: boolean;
4
- }];
1
+ type Options = [
2
+ {
3
+ allowSelfGuard: boolean;
4
+ allowCaughtValues: boolean;
5
+ allowTypeGuards: boolean;
6
+ }
7
+ ];
5
8
  declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"noInstanceof", Options, unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
6
9
  name: string;
7
10
  };
@@ -4,6 +4,26 @@ const utils_1 = require("@typescript-eslint/utils");
4
4
  const ancestors_1 = require("../utils/ancestors");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const locals_1 = require("../utils/locals");
7
+ const FUNCTIONS = new Set([
8
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
9
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
10
+ utils_1.AST_NODE_TYPES.FunctionExpression,
11
+ ]);
12
+ /**
13
+ * Whether the check sits inside a function that declares a type predicate.
14
+ * A `value is X` signature is the one place a nominal check states what it is
15
+ * doing: the answer leaves as a narrowed type rather than as a bare boolean,
16
+ * every call site reads the class name once, and the project ends up with one
17
+ * greppable guard per class instead of an `instanceof` in the middle of a
18
+ * method. Only the innermost function counts, so a guard cannot lend its
19
+ * exemption to code that merely follows it.
20
+ */
21
+ const isInsideTypeGuard = (node) => {
22
+ const fn = (0, ancestors_1.closestAncestor)(node, (candidate) => FUNCTIONS.has(candidate.type));
23
+ return (fn !== undefined &&
24
+ 'returnType' in fn &&
25
+ fn.returnType?.typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypePredicate);
26
+ };
7
27
  /** The name of the class a node sits inside, if it sits inside a named one. */
8
28
  const enclosingClass = (node) => {
9
29
  const found = (0, ancestors_1.closestAncestor)(node, (candidate) => candidate.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
@@ -26,10 +46,10 @@ exports.default = (0, createRule_1.createRule)({
26
46
  meta: {
27
47
  type: 'suggestion',
28
48
  docs: {
29
- description: 'Disallow the `instanceof` operator. Type discrimination breaks polymorphism; let the object decide via a method instead.',
49
+ description: 'Disallow the `instanceof` operator. Type discrimination breaks polymorphism; let the object decide via a method instead. A check that has no polymorphic form belongs in a declared `value is X` type guard.',
30
50
  },
31
51
  messages: {
32
- noInstanceof: 'Avoid `instanceof`. Replace type discrimination with a polymorphic method on the object.',
52
+ noInstanceof: 'Avoid `instanceof`. Replace type discrimination with a polymorphic method on the object. If the class is nominal and offers no discriminant, move the check into a function that declares `value is {{name}}` and call that.',
33
53
  },
34
54
  schema: [
35
55
  {
@@ -37,24 +57,34 @@ exports.default = (0, createRule_1.createRule)({
37
57
  properties: {
38
58
  allowSelfGuard: { type: 'boolean' },
39
59
  allowCaughtValues: { type: 'boolean' },
60
+ allowTypeGuards: { type: 'boolean' },
40
61
  },
41
62
  additionalProperties: false,
42
63
  },
43
64
  ],
44
65
  },
45
- defaultOptions: [{ allowSelfGuard: true, allowCaughtValues: true }],
46
- create(context, [{ allowSelfGuard, allowCaughtValues }]) {
66
+ defaultOptions: [
67
+ { allowSelfGuard: true, allowCaughtValues: true, allowTypeGuards: true },
68
+ ],
69
+ create(context, [{ allowSelfGuard, allowCaughtValues, allowTypeGuards }]) {
47
70
  return {
48
71
  'BinaryExpression[operator="instanceof"]'(node) {
49
72
  if (allowSelfGuard && isSelfGuard(node)) {
50
73
  return;
51
74
  }
75
+ if (allowTypeGuards && isInsideTypeGuard(node)) {
76
+ return;
77
+ }
52
78
  if (allowCaughtValues &&
53
79
  node.left.type === utils_1.AST_NODE_TYPES.Identifier &&
54
80
  (0, locals_1.isCaughtBinding)(context.sourceCode.getScope(node), node.left.name)) {
55
81
  return;
56
82
  }
57
- context.report({ node, messageId: 'noInstanceof' });
83
+ context.report({
84
+ node,
85
+ messageId: 'noInstanceof',
86
+ data: { name: context.sourceCode.getText(node.right) },
87
+ });
58
88
  },
59
89
  };
60
90
  },
@@ -7,10 +7,10 @@ exports.default = (0, createRule_1.createRule)({
7
7
  meta: {
8
8
  type: 'suggestion',
9
9
  docs: {
10
- description: 'Disallow returning null. Model absence with an explicit type, an Optional/Maybe, or by throwing.',
10
+ description: 'Disallow returning null. Throw when the value must exist, return an object that answers for the absent case, or — when the return type is already a collection — an empty one.',
11
11
  },
12
12
  messages: {
13
- noNullReturn: 'Returning null leaks absence into callers. Return an explicit empty value, a domain type, or throw.',
13
+ noNullReturn: 'Returning null leaks absence into callers. Throw if the value must exist, or return an object that answers for the absent case. An empty collection models absence only where the return type was already a collection — a zero-or-one array is a null in a box.',
14
14
  },
15
15
  schema: [],
16
16
  },
@@ -1,4 +1,5 @@
1
- declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"noAssertion", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
1
+ type MessageIds = 'noAssertion' | 'nonNullAssertion';
2
+ declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<MessageIds, [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
3
  name: string;
3
4
  };
4
5
  export default _default;
@@ -10,10 +10,11 @@ exports.default = (0, createRule_1.createRule)({
10
10
  meta: {
11
11
  type: 'suggestion',
12
12
  docs: {
13
- description: 'Disallow type assertions, which bypass the type checker. Prefer type guards, generics, or honest types. `as const` is allowed.',
13
+ description: 'Disallow type assertions, which bypass the type checker: `as T`, `<T>x`, and the non-null operator `x!`. Prefer type guards, generics, or honest types. `as const` is allowed.',
14
14
  },
15
15
  messages: {
16
16
  noAssertion: 'Type assertions silence the type checker. Use a type guard, a generic, or a correctly typed value instead.',
17
+ nonNullAssertion: '`!` asserts away a nullable the type says is there. Narrow it with a check that throws, or correct the type if it was never nullable.',
17
18
  },
18
19
  schema: [],
19
20
  },
@@ -29,6 +30,9 @@ exports.default = (0, createRule_1.createRule)({
29
30
  TSTypeAssertion(node) {
30
31
  context.report({ node, messageId: 'noAssertion' });
31
32
  },
33
+ TSNonNullExpression(node) {
34
+ context.report({ node, messageId: 'nonNullAssertion' });
35
+ },
32
36
  };
33
37
  },
34
38
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianjos/eslint-plugin-elegant",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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",