@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 +234 -28
- package/dist/index.d.ts +5 -1
- package/dist/index.js +71 -0
- package/dist/rules/no-any-return.d.ts +4 -0
- package/dist/rules/no-any-return.js +48 -0
- package/dist/rules/no-instanceof.d.ts +7 -4
- package/dist/rules/no-instanceof.js +35 -5
- package/dist/rules/no-null-return.js +2 -2
- package/dist/rules/no-type-assertion.d.ts +2 -1
- package/dist/rules/no-type-assertion.js +5 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -63,14 +63,33 @@ export default [
|
|
|
63
63
|
];
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
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
|
|
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
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
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.
|
|
772
|
-
|
|
773
|
-
|
|
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.
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
3
|
-
|
|
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: [
|
|
46
|
-
|
|
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({
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|