@noctcore/eslint-plugin-code-quality 0.1.0 → 0.2.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 +10 -0
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/dist/index.cjs +796 -91
- package/dist/index.d.cts +76 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +785 -90
- package/docs/rules/fake-timers-must-be-restored.md +106 -0
- package/docs/rules/interface-prefix-i.md +7 -5
- package/docs/rules/no-bare-date-now.md +8 -4
- package/docs/rules/no-conditional-expect.md +80 -0
- package/docs/rules/no-focused-tests.md +8 -6
- package/docs/rules/no-historical-comments.md +6 -4
- package/docs/rules/no-narration-comments.md +7 -5
- package/docs/rules/no-pr-reference-comments.md +8 -6
- package/docs/rules/no-process-exit.md +18 -0
- package/docs/rules/no-real-network-in-unit-tests.md +80 -0
- package/docs/rules/no-template-trim-empty-ternary.md +4 -4
- package/docs/rules/no-vacuous-expect.md +87 -0
- package/docs/rules/prefer-early-return.md +5 -3
- package/docs/rules/skipped-tests-need-tracking.md +8 -6
- package/package.json +4 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# `noctcore-code-quality/fake-timers-must-be-restored`
|
|
2
|
+
|
|
3
|
+
> A file that installs fake timers must restore real ones.
|
|
4
|
+
|
|
5
|
+
Ported from [tsforge](https://github.com/boringstack-xyz/tsforge) (MIT). See
|
|
6
|
+
[THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
`jest.useFakeTimers()` / `vi.useFakeTimers()` replace the global clock. Without a matching
|
|
11
|
+
`useRealTimers()` the fake clock leaks into later tests in the same worker, and those tests fail or,
|
|
12
|
+
worse, pass for the wrong reason, depending on file order.
|
|
13
|
+
|
|
14
|
+
## What it flags
|
|
15
|
+
|
|
16
|
+
Every call to a `fakeTimerMethods` method (`useFakeTimers` by default, as `name()` or `obj.name()`)
|
|
17
|
+
in a file with no call to a `restoreTimerMethods` method, unless the restore lives in a shared suite
|
|
18
|
+
the file runs.
|
|
19
|
+
|
|
20
|
+
### Shared suites
|
|
21
|
+
|
|
22
|
+
A contract suite often owns the restore: the spec installs fake timers inside a helper it hands to
|
|
23
|
+
the suite, and the suite restores them in its own `afterEach`.
|
|
24
|
+
|
|
25
|
+
```ts prose reason="two files: the rule reads the imported suite from disk"
|
|
26
|
+
// provider.contract-suite.ts
|
|
27
|
+
export function runProviderContract(subject: Subject): void {
|
|
28
|
+
describe('provider contract', () => {
|
|
29
|
+
afterEach(() => jest.useRealTimers());
|
|
30
|
+
// ...
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// fake-provider.contract.spec.ts
|
|
35
|
+
import { runProviderContract } from './provider.contract-suite';
|
|
36
|
+
|
|
37
|
+
const advancePastDeadline = () => {
|
|
38
|
+
jest.useFakeTimers();
|
|
39
|
+
jest.setSystemTime(Date.now() + 86_400_000);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
runProviderContract({ advancePastDeadline });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
With `followImportedSuites` on (the default), the rule follows each relative import whose binding the
|
|
46
|
+
file calls (`runProviderContract(...)` or `suite.run(...)`), resolves it on disk (`.ts`, `.tsx`,
|
|
47
|
+
`.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, `.cjs`, or a directory `index`), and accepts a restore call
|
|
48
|
+
in that module's source. The check is one level deep and text-based. An import that is only
|
|
49
|
+
re-exported, never called, does not count.
|
|
50
|
+
|
|
51
|
+
For a suite the rule cannot resolve (a path alias or a workspace package), name it in
|
|
52
|
+
`sharedSuiteModules`.
|
|
53
|
+
|
|
54
|
+
```ts bad filename=src/session.test.ts
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('expires the session', () => {
|
|
60
|
+
vi.advanceTimersByTime(60_000);
|
|
61
|
+
expect(session.isExpired()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```ts good filename=src/session.test.ts
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.useFakeTimers();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('expires the session', () => {
|
|
75
|
+
vi.advanceTimersByTime(60_000);
|
|
76
|
+
expect(session.isExpired()).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
| Option | Type | Default | Meaning |
|
|
83
|
+
| --- | --- | --- | --- |
|
|
84
|
+
| `fakeTimerMethods` | `string[]` | `["useFakeTimers"]` | Methods that install fake timers. |
|
|
85
|
+
| `restoreTimerMethods` | `string[]` | `["useRealTimers"]` | Methods that restore real timers. One call anywhere in the file satisfies the rule. |
|
|
86
|
+
| `followImportedSuites` | `boolean` | `true` | Accept a restore found in a relative module this file imports and calls. |
|
|
87
|
+
| `sharedSuiteModules` | `string[]` (globs) | `[]` | Import specifiers trusted to restore timers when the file imports and calls them. Supports `*`, `**`, `?` and `{a,b}`. |
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
'noctcore-code-quality/fake-timers-must-be-restored': ['error', {
|
|
91
|
+
fakeTimerMethods: ['useFakeTimers', 'install'],
|
|
92
|
+
restoreTimerMethods: ['useRealTimers', 'uninstall'],
|
|
93
|
+
sharedSuiteModules: ['@acme/testing/*-suite'],
|
|
94
|
+
}]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Caveats
|
|
98
|
+
|
|
99
|
+
Reading an imported suite from disk means an edit to the suite alone does not invalidate
|
|
100
|
+
`eslint --cache` for the spec. The rule caches each suite's source by modification time within one
|
|
101
|
+
process.
|
|
102
|
+
|
|
103
|
+
## When not to use it
|
|
104
|
+
|
|
105
|
+
If your runner restores timers globally (for example a setup file with a global `afterEach` that
|
|
106
|
+
calls `useRealTimers()`).
|
|
@@ -19,12 +19,14 @@ dictated by the module being augmented (`Register`, `Window`).
|
|
|
19
19
|
|
|
20
20
|
Report-only: a rename touches every reference, which a single-file fixer cannot do safely.
|
|
21
21
|
|
|
22
|
-
```ts
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
```ts bad reports=2
|
|
23
|
+
interface UserProfile { id: string; }
|
|
24
|
+
interface Input { value: string; }
|
|
25
|
+
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
```ts good
|
|
28
|
+
interface IUserProfile { id: string; }
|
|
29
|
+
declare global { interface Window { electron: unknown; } } // augmentation
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
## Options
|
|
@@ -16,16 +16,20 @@ branch depends on the real clock. Routing wall-clock reads through a shared `clo
|
|
|
16
16
|
`new Date(value)` with an argument is a **parse** of an explicit instant, not a bare clock read, and
|
|
17
17
|
is never flagged. Files covered by `allowIn` are skipped entirely.
|
|
18
18
|
|
|
19
|
-
```ts
|
|
20
|
-
//
|
|
19
|
+
```ts bad reports=2
|
|
20
|
+
// bare clock reads in business logic
|
|
21
21
|
const start = Date.now();
|
|
22
22
|
const created = new Date();
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts good
|
|
26
|
+
import { now, nowMs } from './clock';
|
|
23
27
|
|
|
24
|
-
//
|
|
28
|
+
// through the clock seam
|
|
25
29
|
const start = nowMs();
|
|
26
30
|
const created = now();
|
|
27
31
|
|
|
28
|
-
//
|
|
32
|
+
// parsing an explicit instant
|
|
29
33
|
const at = new Date('2026-01-01T00:00:00Z');
|
|
30
34
|
```
|
|
31
35
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# `noctcore-code-quality/no-conditional-expect`
|
|
2
|
+
|
|
3
|
+
> An `expect()` that may not run lets a broken test pass.
|
|
4
|
+
|
|
5
|
+
Ported from [tsforge](https://github.com/boringstack-xyz/tsforge) (MIT). See
|
|
6
|
+
[THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
An assertion inside an `if`, a `switch` case, a ternary, a `&&` / `||` / `??` or a `catch` only runs
|
|
11
|
+
when that branch is taken. When the code under test changes so the branch is skipped, the test
|
|
12
|
+
passes with zero assertions. The classic case is `try { await f(); } catch (e) { expect(e)... }`:
|
|
13
|
+
if `f` stops throwing, the test goes green.
|
|
14
|
+
|
|
15
|
+
The rule matches on method names, so it covers Jest, Vitest and Bun alike.
|
|
16
|
+
|
|
17
|
+
## What it flags
|
|
18
|
+
|
|
19
|
+
`expect(...)`, or `obj.expect(...)` on an identifier (`t.expect`, `chai.expect`), whose path up to
|
|
20
|
+
the enclosing test or suite callback crosses the conditional part of a branch. The test of an `if`
|
|
21
|
+
and the left side of a logical expression run unconditionally and are not flagged.
|
|
22
|
+
|
|
23
|
+
The search stops at the enclosing `it` / `test` / `describe` / `suite` callback, so a suite-level
|
|
24
|
+
loop or `if` that generates tests does not flag the expects inside those tests.
|
|
25
|
+
|
|
26
|
+
A test that calls `expect.assertions(n)` or `expect.hasAssertions()` is exempt: a skipped branch
|
|
27
|
+
already fails it.
|
|
28
|
+
|
|
29
|
+
```ts bad filename=src/parse.test.ts reports=2
|
|
30
|
+
it('rejects bad input', async () => {
|
|
31
|
+
try {
|
|
32
|
+
await parse('');
|
|
33
|
+
} catch (error) {
|
|
34
|
+
expect(error).toBeInstanceOf(ParseError);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns the value', () => {
|
|
39
|
+
const result = load();
|
|
40
|
+
if (result.ok) {
|
|
41
|
+
expect(result.value).toBe(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts good filename=src/parse.test.ts
|
|
47
|
+
it('rejects bad input', async () => {
|
|
48
|
+
await expect(parse('')).rejects.toBeInstanceOf(ParseError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns the value', () => {
|
|
52
|
+
expect(load()).toEqual({ ok: true, value: 1 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('parse', () => {
|
|
56
|
+
for (const c of cases) {
|
|
57
|
+
it(c.name, () => {
|
|
58
|
+
expect(parse(c.input)).toEqual(c.output);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
|
+
|
|
66
|
+
| Option | Type | Default | Meaning |
|
|
67
|
+
| --- | --- | --- | --- |
|
|
68
|
+
| `checkLoops` | `boolean` | `false` | Also treat a `for` / `for...in` / `for...of` / `while` / `do...while` body as conditional, since a loop over an empty collection asserts nothing. |
|
|
69
|
+
|
|
70
|
+
`checkLoops` is off by default, unlike the tsforge original. On a real 650-file suite, loops made up
|
|
71
|
+
164 of 173 reports, almost all table-driven loops over literal arrays and constant maps.
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
'noctcore-code-quality/no-conditional-expect': ['error', { checkLoops: true }]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## When not to use it
|
|
78
|
+
|
|
79
|
+
If your suites rely on type-narrowing guards such as `if (result.success) expect(result.data)...`
|
|
80
|
+
after an unconditional `expect(result.success).toBe(true)`, and you do not want to rewrite them.
|
|
@@ -15,13 +15,15 @@ focused test a lint error so it can't merge.
|
|
|
15
15
|
- The Jest/Jasmine focused-call forms `fdescribe(...)`, `fit(...)`, `ddescribe(...)` — but only as a
|
|
16
16
|
bare-identifier callee, so an unrelated `obj.fit(...)` method is not flagged.
|
|
17
17
|
|
|
18
|
-
```ts
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
```ts bad filename=src/example.test.ts reports=3
|
|
19
|
+
it.only('runs', () => {});
|
|
20
|
+
test.concurrent.only('case', () => {});
|
|
21
|
+
fdescribe('suite', () => {});
|
|
22
|
+
```
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
```ts good filename=src/example.test.ts
|
|
25
|
+
it('runs', () => {});
|
|
26
|
+
layout.fit('contain'); // not a test runner
|
|
25
27
|
```
|
|
26
28
|
|
|
27
29
|
## Options
|
|
@@ -15,11 +15,13 @@ Line and block comments (JSDoc `/** … */` blocks are exempt) matching narrow p
|
|
|
15
15
|
`before/after the fix`, `before/after the refactor`, `we/this used to`, `used to be`, `no longer`,
|
|
16
16
|
`kept for backwards/legacy/compat`, `was/were a bug/footgun`, and `historical(ly)`.
|
|
17
17
|
|
|
18
|
-
```ts
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
```ts bad reports=2
|
|
19
|
+
// We used to read process.env directly here.
|
|
20
|
+
// Before the fix this collapsed to {}.
|
|
21
|
+
```
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
```ts good
|
|
24
|
+
// Caps concurrent connections to avoid pool exhaustion.
|
|
23
25
|
```
|
|
24
26
|
|
|
25
27
|
## Options
|
|
@@ -17,12 +17,14 @@ construction: `here we`, `now we`, `first[,] we`, `then[,] we`, `next[,] we`, `f
|
|
|
17
17
|
|
|
18
18
|
A bare leading word ("Next attempt…", "First run…") is fine — only the "we"/"let's" narration form matches.
|
|
19
19
|
|
|
20
|
-
```ts
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
```ts bad reports=2
|
|
21
|
+
// Now we attach the user to the socket.
|
|
22
|
+
// Let's validate the session token.
|
|
23
|
+
```
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
//
|
|
25
|
+
```ts good
|
|
26
|
+
// WHY: Prisma reuses the pooled connection across requests.
|
|
27
|
+
// call next() to continue the middleware chain
|
|
26
28
|
```
|
|
27
29
|
|
|
28
30
|
## Options
|
|
@@ -18,13 +18,15 @@ Comments containing:
|
|
|
18
18
|
- a `PR #123` / `PR 123` reference;
|
|
19
19
|
- a bare `#123` at a word boundary.
|
|
20
20
|
|
|
21
|
-
```ts
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
21
|
+
```ts bad reports=3
|
|
22
|
+
// fixes #123
|
|
23
|
+
// See https://github.com/noctcore/eslint-plugins/pull/42 for context.
|
|
24
|
+
// workaround (#88)
|
|
25
|
+
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
```ts good
|
|
28
|
+
// Trust-proxy depth for single-host Traefik.
|
|
29
|
+
const channel = "#general"; // the string is not a comment
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
## Options
|
|
@@ -16,6 +16,24 @@ Any `process.exit(...)` call in a file **not** covered by `allowIn`. Both the do
|
|
|
16
16
|
(`process.exit()`) and the computed form (`process['exit']()`) are caught so a computed callee
|
|
17
17
|
cannot bypass the rule.
|
|
18
18
|
|
|
19
|
+
```ts bad filename=src/orders/orders.service.ts
|
|
20
|
+
if (!order) {
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts good filename=src/orders/orders.service.ts
|
|
26
|
+
if (!order) {
|
|
27
|
+
throw new Error('order not found');
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Moving the exit to a CLI entrypoint is the other fix:
|
|
32
|
+
|
|
33
|
+
```ts good filename=scripts/migrate.ts relocation
|
|
34
|
+
main().catch(() => process.exit(1));
|
|
35
|
+
```
|
|
36
|
+
|
|
19
37
|
## Options
|
|
20
38
|
|
|
21
39
|
| Option | Type | Default | Meaning |
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# `noctcore-code-quality/no-real-network-in-unit-tests`
|
|
2
|
+
|
|
3
|
+
> Unit tests must not perform real network I/O.
|
|
4
|
+
|
|
5
|
+
Ported from [tsforge](https://github.com/boringstack-xyz/tsforge) (MIT). See
|
|
6
|
+
[THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
A unit test that calls `fetch` or `axios` for real depends on a server, a port, DNS and the network
|
|
11
|
+
being up. It is slow, flaky and order-dependent, and it hides the fact that the code under test has
|
|
12
|
+
no seam for a test double. Mock the client, or move the test to an integration suite where real I/O
|
|
13
|
+
is the point.
|
|
14
|
+
|
|
15
|
+
## What it flags
|
|
16
|
+
|
|
17
|
+
In a unit test file (a path ending in one of `testFileSuffixes`, and not containing any
|
|
18
|
+
`integrationMarkers`):
|
|
19
|
+
|
|
20
|
+
- a call to a `networkCallees` global: `fetch(...)`, `globalThis.fetch(...)`, `window.fetch(...)`;
|
|
21
|
+
- a call to an `httpClients` client or its request methods: `axios(...)`, `axios.get(...)`,
|
|
22
|
+
`axios.post(...)`, and `put` / `patch` / `delete` / `head` / `options` / `request`.
|
|
23
|
+
|
|
24
|
+
It does not flag:
|
|
25
|
+
|
|
26
|
+
- a locally declared double (`const fetch = vi.fn()`); an imported `fetch` is still reported;
|
|
27
|
+
- a global the file stubs (`vi.stubGlobal('fetch', ...)`, `jest.spyOn(globalThis, 'fetch')`,
|
|
28
|
+
`globalThis.fetch = vi.fn()`);
|
|
29
|
+
- a client whose module the file mocks (`vi.mock('axios')`, `jest.mock('node-fetch')`);
|
|
30
|
+
- a method that only shares a name (`repository.fetch(1)`, `store.get('k')`).
|
|
31
|
+
|
|
32
|
+
```ts bad filename=src/api/client.test.ts
|
|
33
|
+
it('loads the profile', async () => {
|
|
34
|
+
const res = await fetch('https://api.example.com/me');
|
|
35
|
+
expect(res.status).toBe(200);
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ts good filename=src/api/client.test.ts
|
|
40
|
+
it('loads the profile', async () => {
|
|
41
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"id":1}')));
|
|
42
|
+
expect(await loadProfile()).toEqual({ id: 1 });
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A test that needs the real server moves to an integration suite:
|
|
47
|
+
|
|
48
|
+
```ts good filename=src/api/client.integration.test.ts relocation
|
|
49
|
+
it('loads the profile', async () => {
|
|
50
|
+
const res = await fetch('https://api.example.com/me');
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
| Option | Type | Default | Meaning |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| `testFileSuffixes` | `string[]` | `.test` / `.spec` with `.ts`, `.tsx`, `.js`, `.jsx` | A file is a unit test when its path ends with one of these. |
|
|
60
|
+
| `integrationMarkers` | `string[]` | `.integration.test.`, `.integration.spec.`, `.e2e.test.`, `.e2e.spec.`, `.e2e-spec.`, `/integration/`, `/e2e/` | A test file whose path, relative to the ESLint working directory, contains one of these is skipped. |
|
|
61
|
+
| `networkCallees` | `string[]` | `["fetch"]` | Global functions that perform network I/O. |
|
|
62
|
+
| `httpClients` | `string[]` | `["axios"]` | HTTP clients whose direct call or request methods perform network I/O. |
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
'noctcore-code-quality/no-real-network-in-unit-tests': ['error', {
|
|
66
|
+
integrationMarkers: ['.integration.', '/e2e/', '.live.'],
|
|
67
|
+
httpClients: ['axios', 'ky'],
|
|
68
|
+
}]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Limitations
|
|
72
|
+
|
|
73
|
+
The rule sees calls in the test file only, not network calls made by the code under test. A request
|
|
74
|
+
intercepted by a setup-file mock server (for example MSW) is still reported when the test file itself
|
|
75
|
+
calls `fetch`; add that file's suffix to `integrationMarkers` or disable the rule for it.
|
|
76
|
+
|
|
77
|
+
## When not to use it
|
|
78
|
+
|
|
79
|
+
If your unit tests deliberately run against a local server started in-process, name those files with
|
|
80
|
+
an integration marker instead of turning the rule off.
|
|
@@ -17,12 +17,12 @@ explicitly if the shape recurs in your codebase.
|
|
|
17
17
|
A `ConditionalExpression` whose test is a `===` / `!==` comparison between an **empty-string literal**
|
|
18
18
|
and a **`.trim()` call on a template literal**, in either order.
|
|
19
19
|
|
|
20
|
-
```ts
|
|
21
|
-
// ✗
|
|
20
|
+
```ts bad reports=2
|
|
22
21
|
const name = `${first} ${last}`.trim() === '' ? email : `${first} ${last}`.trim();
|
|
23
|
-
const
|
|
22
|
+
const label = '' !== `${a}`.trim() ? `${a}`.trim() : fallback;
|
|
23
|
+
```
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
```ts good
|
|
26
26
|
const name = buildDisplayName({ first, last, fallback: email });
|
|
27
27
|
const trimmed = value.trim() === '' ? fallback : value; // not a template literal
|
|
28
28
|
```
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# `noctcore-code-quality/no-vacuous-expect`
|
|
2
|
+
|
|
3
|
+
> A test must assert behaviour that a real regression would break.
|
|
4
|
+
|
|
5
|
+
Ported from [tsforge](https://github.com/boringstack-xyz/tsforge) (MIT). See
|
|
6
|
+
[THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
Some assertions pass for almost any implementation. `expect(typeof handler).toBe('function')` proves
|
|
11
|
+
a binding exists. `expect(true).toBe(true)` cannot fail. A test whose only assertion is
|
|
12
|
+
`toBeDefined()` or `toBeTruthy()` stays green when the function returns the wrong object, the wrong
|
|
13
|
+
string or the wrong number. These tests add to the count and protect nothing.
|
|
14
|
+
|
|
15
|
+
The rule matches on method names, not on a runner import, so it covers Jest, Vitest and Bun alike.
|
|
16
|
+
|
|
17
|
+
## What it flags
|
|
18
|
+
|
|
19
|
+
- `typeofExpect`: `expect(typeof x).toBe('<typeof result>')`, also with `toEqual`, `toStrictEqual`
|
|
20
|
+
and `.not`.
|
|
21
|
+
- `tautologyExpect`: `expect(<literal>).toBe(<same literal>)`, also with `toEqual` / `toStrictEqual`.
|
|
22
|
+
- `soleWeakExpect`: a test (`it` / `test`, including `.concurrent`, `.each` and other modifiers)
|
|
23
|
+
whose only assertion is a weak matcher.
|
|
24
|
+
|
|
25
|
+
A test counts every `expect(...)` matcher plus any call matching `assertionCallees`, so a weak
|
|
26
|
+
`expect` next to `assert.equal(...)`, `expectValidUser(...)` or supertest's `.expect(200)` is fine.
|
|
27
|
+
|
|
28
|
+
```ts bad filename=src/token.test.ts reports=3
|
|
29
|
+
it('should be defined', () => {
|
|
30
|
+
expect(service).toBeDefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns a token', () => {
|
|
34
|
+
expect(typeof issueToken()).toBe('string');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('works', () => {
|
|
38
|
+
expect(true).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts good filename=src/token.test.ts
|
|
43
|
+
it('issues a signed token for the user', () => {
|
|
44
|
+
const token = issueToken({ userId: 'u-1' });
|
|
45
|
+
expect(verify(token)).toEqual({ userId: 'u-1' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('creates the user', () => {
|
|
49
|
+
const user = create({ name: 'ada' });
|
|
50
|
+
expect(user).toBeDefined();
|
|
51
|
+
expect(user.name).toBe('ada');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('clears the key', () => {
|
|
55
|
+
cache.delete('k');
|
|
56
|
+
expect(cache.get('k')).toBeUndefined(); // a specific absence, not a weak check
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
| Option | Type | Default | Meaning |
|
|
63
|
+
| --- | --- | --- | --- |
|
|
64
|
+
| `weakMatchers` | `string[]` | `["toBeDefined", "toBeTruthy", "toBeFalsy", "not.toBeUndefined"]` | Matchers that cannot carry a test alone. Prefix `not.` for the negated form. |
|
|
65
|
+
| `assertionCallees` | `string[]` (regex sources) | `["^assert", "^expect\\w", "\\.expect$"]` | Calls that also count as an assertion. Matched against `name`, `obj.name` (member on an identifier) or `.name` (any other member). |
|
|
66
|
+
|
|
67
|
+
`toBeUndefined`, `toBeNull` and `not.toBeNull` are not weak by default: each pins one specific
|
|
68
|
+
value, and `expect(container.querySelector('nav')).not.toBeNull()` is a real presence check.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
// Treat toBeUndefined as weak too, and count a project helper as an assertion.
|
|
72
|
+
'noctcore-code-quality/no-vacuous-expect': ['error', {
|
|
73
|
+
weakMatchers: ['toBeDefined', 'toBeTruthy', 'toBeFalsy', 'toBeUndefined'],
|
|
74
|
+
assertionCallees: ['^assert', '^expect\\w', '\\.expect$', '^verifySnapshot$'],
|
|
75
|
+
}]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Limitations
|
|
79
|
+
|
|
80
|
+
Assertions are counted syntactically inside the test callback. An assertion hidden in a helper whose
|
|
81
|
+
name does not match `assertionCallees` is not seen. A sole `toBeTruthy()` on a Testing Library
|
|
82
|
+
`getBy*` query is reported even though the query itself throws when the element is missing; assert
|
|
83
|
+
with a matcher that states the intent instead.
|
|
84
|
+
|
|
85
|
+
## When not to use it
|
|
86
|
+
|
|
87
|
+
In a smoke suite whose only purpose is to prove modules load.
|
|
@@ -18,16 +18,18 @@ The **last** statement of a function block that is an `if` with:
|
|
|
18
18
|
A single-statement `if`, an `if/else`, or an `if` that is not the final statement is left alone —
|
|
19
19
|
those are not body-wraps.
|
|
20
20
|
|
|
21
|
-
```ts
|
|
22
|
-
//
|
|
21
|
+
```ts bad
|
|
22
|
+
// the whole body is wrapped
|
|
23
23
|
function handle(x) {
|
|
24
24
|
if (x) {
|
|
25
25
|
doA();
|
|
26
26
|
doB();
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
```
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
```ts good
|
|
32
|
+
// guard clause
|
|
31
33
|
function handle(x) {
|
|
32
34
|
if (!x) {
|
|
33
35
|
return;
|
|
@@ -21,16 +21,18 @@ window above it.
|
|
|
21
21
|
Faithful to the original text-scanning implementation, the source is scanned line by line, so a marker
|
|
22
22
|
in a trailing comment, a preceding comment, or anywhere in the lookback window is honoured.
|
|
23
23
|
|
|
24
|
-
```ts
|
|
25
|
-
//
|
|
24
|
+
```ts bad filename=src/example.test.ts reports=2
|
|
25
|
+
// untracked
|
|
26
|
+
it.skip('later', () => {});
|
|
27
|
+
xdescribe('later', () => {});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts good filename=src/example.test.ts
|
|
31
|
+
// tracked
|
|
26
32
|
// TODO(@alice): flaky under CI
|
|
27
33
|
it.skip('later', () => {});
|
|
28
34
|
|
|
29
35
|
it.skip('later', () => {}); // https://github.com/org/repo/issues/1
|
|
30
|
-
|
|
31
|
-
// ✗ untracked
|
|
32
|
-
it.skip('later', () => {});
|
|
33
|
-
xdescribe('later', () => {});
|
|
34
36
|
```
|
|
35
37
|
|
|
36
38
|
## Options
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-code-quality",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Portable code-quality, comment-hygiene, and test-discipline ESLint rules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
20
|
"docs",
|
|
21
|
-
"README.md"
|
|
21
|
+
"README.md",
|
|
22
|
+
"THIRD_PARTY_NOTICES.md"
|
|
22
23
|
],
|
|
23
24
|
"sideEffects": false,
|
|
24
25
|
"keywords": [
|
|
@@ -59,6 +60,6 @@
|
|
|
59
60
|
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
60
61
|
"tsup": "^8.5.1",
|
|
61
62
|
"typescript": "^5.6.0",
|
|
62
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^4"
|
|
63
64
|
}
|
|
64
65
|
}
|