@tianjos/eslint-plugin-elegant 0.8.0 → 0.10.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
@@ -63,14 +63,33 @@ export default [
63
63
  ];
64
64
  ```
65
65
 
66
- A complete, copy-pasteable example (including test-file overrides) lives in
66
+ Adopting this on a codebase that already exists? Spread
67
+ `elegant.configs.starter` instead — same rules, with the four heaviest demoted
68
+ so the first run gives you a list you can work through. See
69
+ [Adopting on an existing codebase](#adopting-on-an-existing-codebase).
70
+
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
67
76
  [`eslint.config.example.mjs`](./eslint.config.example.mjs).
68
77
 
69
78
  ## Rules
70
79
 
71
- The `recommended` config enables every custom rule plus two native ones,
72
- [`max-params`](https://eslint.org/docs/latest/rules/max-params) and
73
- [`no-else-return`](https://eslint.org/docs/latest/rules/no-else-return).
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
82
+ [`no-else-return`](https://eslint.org/docs/latest/rules/no-else-return):
83
+
84
+ - **`recommended`** — the severities in the table below. What the plugin
85
+ argues for.
86
+ - **`starter`** — the same rules with the four heaviest demoted, for adopting
87
+ on a codebase that already exists. See
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).
74
93
 
75
94
  | Rule | Source | What it catches | `recommended` |
76
95
  | -------------------------------------- | ------ | ------------------------------------------------------------------------------- | ------------- |
@@ -78,7 +97,8 @@ The `recommended` config enables every custom rule plus two native ones,
78
97
  | `elegant/max-class-methods` | custom | Classes with more methods than the configured `max` (constructors excluded) | `warn` (max 10) |
79
98
  | `elegant/max-class-dependencies` | custom | Classes depending on more distinct collaborators than `max` (constructor injections plus `new`) | `warn` (max 4) |
80
99
  | `elegant/max-class-fields` | custom | Classes holding more instance fields than `max` (declared fields plus parameter properties) | `warn` (max 5) |
81
- | `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` |
82
102
  | `elegant/no-null-return` | custom | `return null` statements | `error` |
83
103
  | `elegant/no-public-mutable-props` | custom | Public, non-`readonly` class properties and public constructor parameter props | `error` |
84
104
  | `elegant/no-logic-in-constructor` | custom | Any constructor code beyond `this.field = value` stores and a `super(...)` call | `error` |
@@ -178,10 +198,74 @@ Assertions silence the type checker. Reach for a type guard, a generic, or a
178
198
  correctly typed value instead. `as const` is permitted because it narrows rather
179
199
  than widens.
180
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
+
181
248
  #### `no-null-return`
182
249
 
183
- Keeps absence out of return values; model it with an explicit domain type or
184
- 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
+ ```
185
269
 
186
270
  #### `no-public-mutable-props`
187
271
 
@@ -231,6 +315,49 @@ predictable. Parameter properties (`constructor(private readonly x: T)`) and a
231
315
  leading `super(...)` are allowed; computed right-hand sides (`this.x = x * 2`,
232
316
  `this.items = items.slice()`) and any non-assignment statement are flagged.
233
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
+
234
361
  #### `no-getters-setters`
235
362
 
236
363
  Getters and setters turn objects into data bags; prefer methods that expose
@@ -245,7 +372,7 @@ around repositories and framework hooks, so it stays off in `recommended`.
245
372
  the object. Pairs with `no-type-assertion` to keep type-based branching out of
246
373
  the codebase.
247
374
 
248
- 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
249
376
  polymorphic alternative to reach for.
250
377
 
251
378
  **A self-guard** — `other instanceof Money` inside `class Money`. Value
@@ -261,6 +388,22 @@ method on the value can stand in, because at that point the value has no known
261
388
  methods. Resolved through the scope chain, so the narrowing still counts one
262
389
  closure deeper. Off via `{ allowCaughtValues: false }`.
263
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
+
264
407
  ```ts
265
408
  // allowed
266
409
  class Money {
@@ -271,12 +414,19 @@ class Money {
271
414
  try { charge(); } catch (error) {
272
415
  if (error instanceof HttpException) { log(error.getStatus()); }
273
416
  }
417
+ export const isIsoDate = (value: unknown): value is IsoDate =>
418
+ value instanceof IsoDate;
274
419
 
275
420
  // still reported
276
421
  if (shape instanceof Circle) { draw(); }
277
422
  function handle(error: HttpException) { return error instanceof HttpException; }
423
+ function isIsoDate(value: unknown): boolean { return value instanceof IsoDate; }
278
424
  ```
279
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
+
280
430
  An error that arrives as a plain parameter rather than through `catch` — Nest's
281
431
  `ExceptionFilter.catch(exception, host)`, an RxJS `catchError` callback — is
282
432
  **not** covered, because a parameter's type is whatever the signature says and
@@ -768,22 +918,44 @@ value from the wire format of a database column. `no-type-assertion` counts
768
918
  `x as unknown as T` twice, once per assertion, which is arguably correct.
769
919
 
770
920
  None of that makes them wrong — it makes them rules you adopt on purpose
771
- rather than inherit. **Enable the preset, then take the top of that table back
772
- to `warn` or `off` and work down it.** The rules below `no-type-assertion` sum
773
- to 1.75 per file, which is a starting point you can actually clear:
921
+ rather than inherit. That is what `starter` is: every rule `recommended`
922
+ carries, with those four demoted, leaving the 1.75 per file below them — a
923
+ list somebody can actually work through.
774
924
 
775
925
  ```js
776
926
  rules: {
777
- ...elegant.configs.recommended.rules,
927
+ ...elegant.configs.starter.rules,
928
+ }
929
+ ```
930
+
931
+ | | `recommended` | `starter` |
932
+ | --- | --- | --- |
933
+ | `no-comments-in-function-body` | `error` | `off` |
934
+ | `no-interpolated-log-message` | `error` | `warn` |
935
+ | `no-null` | `error` | `warn` |
936
+ | `no-type-assertion` | `error` | `warn` |
937
+ | everything else | unchanged | unchanged |
778
938
 
779
- // The four that need a plan of their own. Re-enable one at a time.
780
- 'elegant/no-comments-in-function-body': 'off',
781
- 'elegant/no-interpolated-log-message': 'warn',
782
- 'elegant/no-null': 'warn',
783
- 'elegant/no-type-assertion': 'warn',
939
+ Promote them back one at a time as you clear them, and switch to
940
+ `recommended` once nothing is left:
941
+
942
+ ```js
943
+ rules: {
944
+ ...elegant.configs.starter.rules,
945
+ 'elegant/no-null': 'error', // cleared, so hold the line
784
946
  }
785
947
  ```
786
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
+
787
959
  Numbers from one corpus are indicative, not universal. Run
788
960
  `npx eslint . --format json` on your own and sort by rule before deciding
789
961
  anything — the shape of your code decides which of these rules is a signal and
@@ -791,29 +963,63 @@ which is a migration.
791
963
 
792
964
  ### Relaxing rules in test files
793
965
 
794
- Tests routinely use flag arguments and larger fixtures. Add a second config
795
- block scoped to your spec globs:
966
+ Spread `tests` in a config block scoped to your spec globs:
796
967
 
797
968
  ```js
798
969
  {
799
970
  files: ['**/*.spec.ts', '**/*.test.ts', '**/*.e2e-spec.ts'],
800
- rules: {
801
- 'elegant/no-boolean-param': 'off',
802
- 'elegant/max-class-methods': 'off',
803
- 'elegant/max-class-dependencies': 'off',
804
- 'elegant/max-class-fields': 'off',
805
- 'elegant/no-comments-in-function-body': 'off',
806
- 'max-params': 'off',
807
- },
971
+ rules: { ...elegant.configs.tests.rules },
808
972
  }
809
973
  ```
810
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 },
1007
+ }
1008
+ ```
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
+
811
1015
  ## Compatibility
812
1016
 
813
1017
  The package ships a single CommonJS build that is consumable as both
814
1018
  `require('@tianjos/eslint-plugin-elegant')` and an ESM
815
1019
  `import elegant from '@tianjos/eslint-plugin-elegant'`. The exported object
816
- exposes `{ meta, rules, configs }`.
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
1022
+ `tests/dist.test.ts`.
817
1023
 
818
1024
  ## Prior art
819
1025
 
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 }],
@@ -88,5 +91,73 @@ plugin.configs.recommended = {
88
91
  'no-else-return': ['error', { allowElseIf: false }],
89
92
  },
90
93
  };
94
+ /**
95
+ * The four rules that carry most of the friction on code that already exists.
96
+ * Each encodes a defensible position whose boundary this plugin cannot see —
97
+ * a comment that wants to be a function, a logging convention already chosen,
98
+ * `null` as the wire format of a column, an assertion widening a type — so on
99
+ * a mature codebase they report by the thousand. `recommended` keeps them at
100
+ * `error` on purpose; `starter` is the door in.
101
+ */
102
+ const NOISIEST = {
103
+ 'elegant/no-comments-in-function-body': 'off',
104
+ 'elegant/no-interpolated-log-message': 'warn',
105
+ 'elegant/no-null': 'warn',
106
+ 'elegant/no-type-assertion': 'warn',
107
+ };
108
+ /**
109
+ * Every rule `recommended` carries, with the four heaviest demoted so the
110
+ * first run on an existing codebase produces a list somebody can work through.
111
+ * Promote them back one at a time; see "Adopting on an existing codebase".
112
+ */
113
+ plugin.configs.starter = {
114
+ name: 'elegant/starter',
115
+ plugins: { elegant: plugin },
116
+ rules: {
117
+ ...plugin.configs.recommended.rules,
118
+ ...NOISIEST,
119
+ },
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
+ };
91
162
  plugin.default = plugin;
92
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.8.0",
3
+ "version": "0.10.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",
@@ -43,7 +43,7 @@
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "lint": "npm run build && eslint .",
45
45
  "typecheck": "tsc -p tsconfig.test.json",
46
- "test": "jest",
46
+ "test": "npm run build && jest",
47
47
  "release": "standard-version --release-as minor",
48
48
  "release:patch": "standard-version --release-as patch",
49
49
  "prepublishOnly": "npm run build && npm test"