@noctcore/eslint-plugin-contracts 0.6.1 → 0.7.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 +74 -32
- package/dist/index.cjs +330 -34
- package/dist/index.d.cts +36 -26
- package/dist/index.d.ts +36 -26
- package/dist/index.js +331 -35
- package/docs/rules/env-var-schema-parity.md +10 -2
- package/docs/rules/fetch-must-check-ok.md +18 -6
- package/docs/rules/money-must-be-decimal.md +14 -5
- package/docs/rules/no-direct-process-env.md +10 -2
- package/docs/rules/no-error-stringify.md +10 -0
- package/docs/rules/require-error-cause.md +17 -13
- package/docs/rules/require-registered-keys.md +13 -4
- package/docs/rules/require-schema-parse-at-boundary.md +101 -7
- package/docs/rules/restrict-throw-to-taxonomy.md +10 -4
- package/docs/rules/schema-enum-field-consistency.md +14 -0
- package/docs/rules/translation-key-exists.md +14 -10
- package/docs/rules/wire-message-naming.md +9 -1
- package/docs/rules/zod-schema-naming.md +10 -0
- package/package.json +7 -7
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Monetary fields typed as the JS `number` primitive lose precision to float rounding — use a Decimal money type.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
Money stored as a JS `number` accumulates IEEE-754 rounding errors (`0.1 + 0.2 !== 0.3`), which is
|
|
@@ -16,10 +20,6 @@ positions that carry real precision risk:
|
|
|
16
20
|
- class properties — `class Invoice { total: number }`
|
|
17
21
|
- annotated variable declarators — `const amount: number = …`
|
|
18
22
|
|
|
19
|
-
Conservative on purpose. Untyped declarations and numeric-literal initializers (`let total = 0`) are
|
|
20
|
-
**not** flagged — those are usually counters/accumulators. Interface and type-literal members
|
|
21
|
-
(`{ amount: number }`) are **out of scope** so non-money type members do not regress.
|
|
22
|
-
|
|
23
23
|
```ts bad reports=2
|
|
24
24
|
class Invoice { total: number; }
|
|
25
25
|
const amount: number = 5;
|
|
@@ -31,6 +31,15 @@ const count: number = 3; // not a money name
|
|
|
31
31
|
interface Payment { amount: number; } // type member, out of scope
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## What it does not flag
|
|
35
|
+
|
|
36
|
+
Conservative on purpose. Untyped declarations and numeric-literal initializers (`let total = 0`) are
|
|
37
|
+
**not** flagged — those are usually counters/accumulators. Interface and type-literal members
|
|
38
|
+
(`{ amount: number }`) are **out of scope** so non-money type members do not regress.
|
|
39
|
+
|
|
40
|
+
- Object-literal properties (`const x = { total: 5 }`).
|
|
41
|
+
- Fields matching `minorUnitPatterns`, and every field in a file listed in `allowedFiles`.
|
|
42
|
+
|
|
34
43
|
## Options
|
|
35
44
|
|
|
36
45
|
| Option | Type | Default | Meaning |
|
|
@@ -47,7 +56,7 @@ interface Payment { amount: number; } // type member, out of scope
|
|
|
47
56
|
}]
|
|
48
57
|
```
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
### Talking to a payment API
|
|
51
60
|
|
|
52
61
|
Stripe and most payment APIs deal in integer minor units: `amount` is a number of cents, and that
|
|
53
62
|
is correct at that boundary. Without `minorUnitPatterns` this rule flags every one of those
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Read environment variables through a typed, validated config accessor — never `process.env` directly.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
`process.env.X` is `string | undefined`, unvalidated, and reachable from anywhere. A typo or a missing
|
|
@@ -27,8 +31,12 @@ import { config } from '@/config';
|
|
|
27
31
|
const isProd = config.isProduction;
|
|
28
32
|
```
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
## What it does not flag
|
|
35
|
+
|
|
36
|
+
- Files matched by the `allowedFiles` glob allowlist are skipped entirely, so bootstrap entrypoints,
|
|
37
|
+
config files, and tests may still read `process.env` directly.
|
|
38
|
+
- `import.meta.env` reads and other `process` properties (`process.environment`, `process.argv`):
|
|
39
|
+
only `process.env` itself is policed.
|
|
32
40
|
|
|
33
41
|
```ts good filename=vite.config.ts relocation
|
|
34
42
|
const isProd = process.env.NODE_ENV === 'production';
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Stringifying an error with `${error}`, `error.toString()`, or `error + ""` drops its cause chain.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
`` `${error}` ``, `error.toString()`, and `error + ""` all coerce an `Error` to its `message` alone,
|
|
@@ -31,6 +35,12 @@ const msg = error instanceof Error ? error.message : String(error);
|
|
|
31
35
|
const m = `${error.message}`;
|
|
32
36
|
```
|
|
33
37
|
|
|
38
|
+
## What it does not flag
|
|
39
|
+
|
|
40
|
+
- Bare `String(error)`, which the guarded extractor idiom relies on.
|
|
41
|
+
- Member reads such as `` `${error.message}` `` or `error.stack`.
|
|
42
|
+
- `x + ""` where `x` is not one of `errorIdentifierNames` (`count + ""`).
|
|
43
|
+
|
|
34
44
|
## Options
|
|
35
45
|
|
|
36
46
|
| Option | Type | Default | Meaning |
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Re-throwing inside a `catch` without `{ cause }` severs the chain to the original error. 🔧
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 🔧 Fixable with `--fix` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
When you catch an error and throw a new one, the new error is what reaches your logger. If you do not
|
|
@@ -20,16 +24,6 @@ try {
|
|
|
20
24
|
## What it flags
|
|
21
25
|
|
|
22
26
|
A `throw new SomeError(...)` inside a `catch` block when **no** argument carries a `cause` property.
|
|
23
|
-
Deliberately conservative:
|
|
24
|
-
|
|
25
|
-
- Only constructors whose simple name ends in `Error` or `Exception` are treated as errors
|
|
26
|
-
(`Error`, `TypeError`, `ValidationError`, `HttpException`). Throwing `new Response(...)` for
|
|
27
|
-
control flow is not policed.
|
|
28
|
-
- Fires only when the enclosing catch binds a plain identifier (`catch (err)`). A parameterless
|
|
29
|
-
`catch {}` or a destructured binding (`catch ({ message })`) has no single name to attach, so the
|
|
30
|
-
throw is left alone.
|
|
31
|
-
- Any `cause` property (whatever its value), and any spread the rule cannot see through, counts as
|
|
32
|
-
"has a cause" and suppresses the report — a hand-written cause is never second-guessed.
|
|
33
27
|
|
|
34
28
|
A `throw` nested in a closure declared inside the catch is still flagged: the binding is genuinely in
|
|
35
29
|
scope there. Nested `try/catch` uses the nearest binding.
|
|
@@ -47,7 +41,7 @@ try { work(); } catch (err) { throw new Error('failed', { cause: err }); }
|
|
|
47
41
|
try { work(); } catch (err) { throw err; }
|
|
48
42
|
```
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
### Fix
|
|
51
45
|
|
|
52
46
|
Autofix attaches `{ cause: <binding> }`:
|
|
53
47
|
|
|
@@ -58,9 +52,19 @@ Autofix attaches `{ cause: <binding> }`:
|
|
|
58
52
|
A zero-argument `new SomeError()` is **reported but not autofixed** — inserting an options object as
|
|
59
53
|
the first argument could clobber a positional message the rule cannot see.
|
|
60
54
|
|
|
61
|
-
##
|
|
55
|
+
## What it does not flag
|
|
62
56
|
|
|
63
|
-
|
|
57
|
+
Deliberately conservative:
|
|
58
|
+
|
|
59
|
+
- Only constructors whose simple name ends in `Error` or `Exception` are treated as errors
|
|
60
|
+
(`Error`, `TypeError`, `ValidationError`, `HttpException`). Throwing `new Response(...)` for
|
|
61
|
+
control flow is not policed.
|
|
62
|
+
- Fires only when the enclosing catch binds a plain identifier (`catch (err)`). A parameterless
|
|
63
|
+
`catch {}` or a destructured binding (`catch ({ message })`) has no single name to attach, so the
|
|
64
|
+
throw is left alone.
|
|
65
|
+
- Any `cause` property (whatever its value), and any spread the rule cannot see through, counts as
|
|
66
|
+
"has a cause" and suppresses the report — a hand-written cause is never second-guessed.
|
|
67
|
+
- A bare re-throw of the caught error (`throw err`).
|
|
64
68
|
|
|
65
69
|
## When not to use it
|
|
66
70
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> The key/name argument of a configured sink API must be an imported constant, not a raw string.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
⚙️ Opt-in: `off` in `recommended`; needs options (see Options) · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
String keys threaded into sink APIs — storage slots, event channels, feature flags, query-cache keys
|
|
@@ -26,9 +30,18 @@ localStorage.getItem(USER_PROFILE_KEY);
|
|
|
26
30
|
emitter.on(TASK_DONE, handler);
|
|
27
31
|
```
|
|
28
32
|
|
|
33
|
+
## What it does not flag
|
|
34
|
+
|
|
29
35
|
Only string literals are flagged. An already-imported identifier, a template literal, or any computed
|
|
30
36
|
expression is left alone — those are not the raw-string smell.
|
|
31
37
|
|
|
38
|
+
`callee` is matched against the call's dotted identifier path (`localStorage.getItem`, `emitter.on`).
|
|
39
|
+
A callee that is computed or not a plain identifier chain (`this.emitter.on`, `obj[k].on`, `a().b`)
|
|
40
|
+
cannot be matched and is skipped.
|
|
41
|
+
|
|
42
|
+
Calls to callees not listed in `sinks` (`sessionStorage.getItem` when only `localStorage.getItem` is
|
|
43
|
+
configured) are not policed.
|
|
44
|
+
|
|
32
45
|
## Options
|
|
33
46
|
|
|
34
47
|
| Option | Type | Default | Meaning |
|
|
@@ -51,10 +64,6 @@ key sinks to hard-code, so you declare which callees matter for your project.
|
|
|
51
64
|
}]
|
|
52
65
|
```
|
|
53
66
|
|
|
54
|
-
`callee` is matched against the call's dotted identifier path (`localStorage.getItem`, `emitter.on`).
|
|
55
|
-
A callee that is computed or not a plain identifier chain (`this.emitter.on`, `obj[k].on`, `a().b`)
|
|
56
|
-
cannot be matched and is skipped.
|
|
57
|
-
|
|
58
67
|
## When not to use it
|
|
59
68
|
|
|
60
69
|
If your keys are already constants, or you have no registry module to import from, leave `sinks` empty
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Parse external boundary data at runtime — don't assert its shape with `as T`.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
Opt-in: `off` in `recommended` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
External data — a fetch body, a `JSON.parse` result, a message-event payload — has whatever shape the
|
|
@@ -17,7 +21,8 @@ const user = UserSchema.parse(await res.json()); // validated
|
|
|
17
21
|
## What it flags
|
|
18
22
|
|
|
19
23
|
This is a **conservative syntactic slice** of a concept that is fully general only with type
|
|
20
|
-
information. It flags a cast
|
|
24
|
+
information. It flags a cast whose target is a **shape claim** (a named type, an array, or a union
|
|
25
|
+
containing one: `as User`, `as User[]`, `as User | null`) applied to a boundary read:
|
|
21
26
|
|
|
22
27
|
```ts bad reports=3
|
|
23
28
|
const user = JSON.parse(raw) as User;
|
|
@@ -27,18 +32,107 @@ const fetched = (await res.json()) as User;
|
|
|
27
32
|
|
|
28
33
|
```ts good
|
|
29
34
|
const user = UserSchema.parse(JSON.parse(raw));
|
|
35
|
+
const users = UserSchema.array().parse(JSON.parse(raw));
|
|
30
36
|
const fetched = UserSchema.parse(await res.json());
|
|
31
|
-
const data = JSON.parse(raw) as unknown; // safe widening, not a shape claim
|
|
32
37
|
```
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
The boundary reads it knows:
|
|
40
|
+
|
|
41
|
+
| Source | Example |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `JSON.parse(...)` | covers web storage and OpenAI `call.function.arguments` fed to it |
|
|
44
|
+
| `await <expr>.json()` | a fetch `Response` body |
|
|
45
|
+
| `localStorage.getItem` / `sessionStorage.getItem` | also through `window.` / `globalThis.` |
|
|
46
|
+
| `URLSearchParams` `.get` / `.getAll` | `new URLSearchParams(...)`, `<x>.searchParams`, a `searchParams` binding, or a `const` bound to one of those |
|
|
47
|
+
| `event.data` in a `message` listener | `x.addEventListener('message', fn)`, `x.onmessage = fn`, `onmessage = fn`, including a `{ data }` parameter |
|
|
48
|
+
| Anthropic `tool_use` `.input` | `block.input` inside `if (block.type === 'tool_use')`, a `?:` / `&&` guard, `case 'tool_use':`, a `.find((b) => b.type === 'tool_use')` result, or a `.filter(...)` of that shape followed by `.map` / `.flatMap` / `.forEach` |
|
|
49
|
+
| `boundaries` option | any callee you list |
|
|
50
|
+
|
|
51
|
+
```ts bad reports=5
|
|
52
|
+
const prefs = JSON.parse(localStorage.getItem('prefs') ?? '{}') as Prefs;
|
|
53
|
+
const sort = new URLSearchParams(location.search).get('sort') as Sort;
|
|
54
|
+
window.addEventListener('message', (event) => handle(event.data as Message));
|
|
55
|
+
const args = JSON.parse(call.function.arguments) as WeatherArgs;
|
|
56
|
+
if (block.type === 'tool_use') run(block.input as WeatherArgs);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts good
|
|
60
|
+
const prefs = PrefsSchema.parse(JSON.parse(localStorage.getItem('prefs') ?? '{}'));
|
|
61
|
+
const sort = SortSchema.parse(new URLSearchParams(location.search).get('sort'));
|
|
62
|
+
window.addEventListener('message', (event) => handle(MessageSchema.parse(event.data)));
|
|
63
|
+
const args = WeatherArgsSchema.parse(JSON.parse(call.function.arguments));
|
|
64
|
+
if (block.type === 'tool_use') run(WeatherArgsSchema.parse(block.input));
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Through a `const`
|
|
68
|
+
|
|
69
|
+
A boundary value stored in a `const` and cast later in the same function is flagged too:
|
|
70
|
+
|
|
71
|
+
```ts bad
|
|
72
|
+
async function loadUser(res: Response) {
|
|
73
|
+
const raw = await res.json();
|
|
74
|
+
return raw as User;
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```ts good
|
|
79
|
+
async function loadUser(res: Response) {
|
|
80
|
+
const raw = await res.json();
|
|
81
|
+
return UserSchema.parse(raw);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The binding is followed only when nothing could have checked the value first, so the rule gives the
|
|
86
|
+
benefit of the doubt whenever it cannot prove otherwise:
|
|
87
|
+
|
|
88
|
+
- Only a single `const name = <boundary>` is followed, never `let`, `var`, a destructuring pattern
|
|
89
|
+
or a parameter. Chains of such consts (`const body = await res.json(); const raw = body;`) are
|
|
90
|
+
followed.
|
|
91
|
+
- The cast must be in the same function as the declaration.
|
|
92
|
+
- Any read of the binding before the cast other than another `as` cast (a guard like
|
|
93
|
+
`isUser(raw)`, an `assertUser(raw)` call, an `'id' in raw` check, passing it to a function,
|
|
94
|
+
mutating a property) keeps the rule silent, as does any read from a nested function. Reads after
|
|
95
|
+
the cast do not excuse it.
|
|
96
|
+
|
|
97
|
+
```ts prose reason="shows what the rule deliberately leaves alone, not a fix for an example above"
|
|
98
|
+
async function loadUser(res: Response) {
|
|
99
|
+
const raw = await res.json();
|
|
100
|
+
assertUser(raw); // might have validated it: not flagged
|
|
101
|
+
return raw as User;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## What it does not flag
|
|
106
|
+
|
|
107
|
+
- Casts to `unknown`, `any`, `const` or a primitive keyword (`as string | null`): the safe or
|
|
108
|
+
neutral forms.
|
|
109
|
+
- `satisfies`, which checks the value against the type instead of asserting it.
|
|
110
|
+
- A cast of the parse result (`UserSchema.parse(raw) as User`): the schema already checked it.
|
|
111
|
+
- Look-alikes that are not boundary reads: `cache.getItem(...)`, `map.get(...)`,
|
|
112
|
+
`headers.get(...)`, `.data` outside a `message` listener, `.input` without a `tool_use` guard.
|
|
113
|
+
- A boundary value that is neither cast directly nor bound by a followed `const` (a property of
|
|
114
|
+
an object, a function return value). That needs a type-aware setup.
|
|
38
115
|
|
|
39
116
|
## Options
|
|
40
117
|
|
|
41
|
-
|
|
118
|
+
| Option | Type | Default | Meaning |
|
|
119
|
+
| --- | --- | --- | --- |
|
|
120
|
+
| `boundaries` | `string[]` | `[]` | Extra callees whose result is boundary data: a bare name or a dotted path. |
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
'noctcore-contracts/require-schema-parse-at-boundary': ['error', {
|
|
124
|
+
// `readBody(event) as T` and `(await readBody(event)) as T` are then flagged.
|
|
125
|
+
boundaries: ['readBody', 'ipcRenderer.invoke'],
|
|
126
|
+
}]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```ts bad options={"boundaries":["readBody"]}
|
|
130
|
+
const body = (await readBody(event)) as Body;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts good
|
|
134
|
+
const body = BodySchema.parse(await readBody(event));
|
|
135
|
+
```
|
|
42
136
|
|
|
43
137
|
## When not to use it
|
|
44
138
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> `throw` only members of your error taxonomy — never an ad hoc built-in nor a bare value.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
A codebase that throws a curated set of error types can handle them exhaustively at the boundary:
|
|
@@ -15,10 +19,6 @@ breaks that: a non-Error carries no stack and no cause, and an unclassified erro
|
|
|
15
19
|
- `throw new SomethingError(...)` whose constructor is **not** in the `allow` list.
|
|
16
20
|
- `throw <non-Error value>` — a string, number, boolean, template literal, object, or array literal.
|
|
17
21
|
|
|
18
|
-
Conservative on the ambiguous forms. A bare identifier (`throw err` — the re-throw), a member
|
|
19
|
-
(`throw ctx.error`), and a call (`throw makeError()`) are all left alone: a syntactic rule cannot know
|
|
20
|
-
whether they resolve to an Error, and re-throwing a caught error is the most common `throw` there is.
|
|
21
|
-
|
|
22
22
|
```ts bad reports=3
|
|
23
23
|
// built-in not in the taxonomy
|
|
24
24
|
throw new TypeError('bad');
|
|
@@ -34,6 +34,12 @@ throw new Error('boom');
|
|
|
34
34
|
try { work(); } catch (err) { throw err; }
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
## What it does not flag
|
|
38
|
+
|
|
39
|
+
Conservative on the ambiguous forms. A bare identifier (`throw err` — the re-throw), a member
|
|
40
|
+
(`throw ctx.error`), and a call (`throw makeError()`) are all left alone: a syntactic rule cannot know
|
|
41
|
+
whether they resolve to an Error, and re-throwing a caught error is the most common `throw` there is.
|
|
42
|
+
|
|
37
43
|
## Options
|
|
38
44
|
|
|
39
45
|
| Option | Type | Default | Meaning |
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
> A field that is an enum in one zod object schema must not be `z.string()` in another schema of the
|
|
4
4
|
> same module.
|
|
5
5
|
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
6
10
|
## Why
|
|
7
11
|
|
|
8
12
|
When schemas double as wire types (tRPC procedures, a shared contract package), the output schema is
|
|
@@ -50,6 +54,16 @@ export const ticketOutput = z.object({
|
|
|
50
54
|
});
|
|
51
55
|
```
|
|
52
56
|
|
|
57
|
+
## What it does not flag
|
|
58
|
+
|
|
59
|
+
- `z.union([z.string(), z.null()])` and any `z.string()` chain with `.pipe()` or `.transform()`:
|
|
60
|
+
neither is a plain string.
|
|
61
|
+
- A single `z.literal('X')`: a constant, not an enum.
|
|
62
|
+
- An imported identifier, unless its name matches `enumIdentifierPattern` (see Options).
|
|
63
|
+
- An enum in another file: the scope is one module.
|
|
64
|
+
- Keys in `ignoreFields`, computed keys, and schemas built from a namespace not listed in
|
|
65
|
+
`zodIdentifiers`.
|
|
66
|
+
|
|
53
67
|
## Options
|
|
54
68
|
|
|
55
69
|
| Option | Type | Default | Meaning |
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Every static translation key must exist in the catalog of the namespace in scope.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
⚙️ Opt-in: `off` in `recommended`; needs options (see Options) · 💭 Type information: used when available
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
i18next does not fail on a missing key. It renders the key itself (`admin.portalAccounts.revokeTitle`)
|
|
@@ -56,7 +60,7 @@ Plural forms (`key_one`, `key_few`, `key_ordinal_other`) answer for `key` only w
|
|
|
56
60
|
`count`, and context variants (`key_male`) only when it passes `context`, because without them
|
|
57
61
|
i18next looks up the bare key and misses. A subtree or array answers only with `returnObjects`.
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
## What it does not flag
|
|
60
64
|
|
|
61
65
|
Anything it cannot resolve statically: a variable key (`t(someKey)`), a template key
|
|
62
66
|
(`` t(`status.${s}`) ``), a key computed by a helper, a namespace held in an identifier it cannot
|
|
@@ -64,6 +68,15 @@ resolve (an import not listed in `namespaceIdentifiers`), an options bag it cann
|
|
|
64
68
|
(`t('k', opts)`, `{ ...opts }`, `{ ns: someNs }`), and a `t` parameter with no `TFunction` type. The
|
|
65
69
|
rule stays silent on those rather than guessing.
|
|
66
70
|
|
|
71
|
+
### Dead keys are out of scope
|
|
72
|
+
|
|
73
|
+
The reverse check, "a catalog key nothing uses", is not something a per-file ESLint rule can do
|
|
74
|
+
soundly: it needs every source file at once, and ESLint may lint one file (editor), a subset
|
|
75
|
+
(`--cache`, lint-staged), or shard files across workers. Worse, keys routinely flow as data
|
|
76
|
+
(navigation tables, key-builder helpers, `` `errors:${code}` ``), which no call-site analysis
|
|
77
|
+
sees. Run it as a whole-tree check instead, one that unions static keys, template-key prefixes
|
|
78
|
+
and string literals that equal a key.
|
|
79
|
+
|
|
67
80
|
## Options
|
|
68
81
|
|
|
69
82
|
| Option | Type | Default | Meaning |
|
|
@@ -140,15 +153,6 @@ found one real bug (`t('common.cancel')` in the default namespace, which renders
|
|
|
140
153
|
namespace registered at runtime only inside a test (`registerFeatureNamespace('late-arrival', ...)`)
|
|
141
154
|
is reported as unknown; exclude test files or add a catalog entry for it.
|
|
142
155
|
|
|
143
|
-
## Dead keys are out of scope
|
|
144
|
-
|
|
145
|
-
The reverse check, "a catalog key nothing uses", is not something a per-file ESLint rule can do
|
|
146
|
-
soundly: it needs every source file at once, and ESLint may lint one file (editor), a subset
|
|
147
|
-
(`--cache`, lint-staged), or shard files across workers. Worse, keys routinely flow as data
|
|
148
|
-
(navigation tables, key-builder helpers, `` `errors:${code}` ``), which no call-site analysis
|
|
149
|
-
sees. Run it as a whole-tree check instead, one that unions static keys, template-key prefixes
|
|
150
|
-
and string literals that equal a key.
|
|
151
|
-
|
|
152
156
|
## When not to use it
|
|
153
157
|
|
|
154
158
|
If your keys are mostly computed, or your catalogs are not JSON files on disk (fetched from a TMS at
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> A message-schema's `type` discriminant must be the kebab-case of its const name minus its role suffix. 🔧
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 🔧 Fixable with `--fix` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
When wire messages are modelled as zod objects with a `type: z.literal('…')` discriminant, the const
|
|
@@ -29,7 +33,11 @@ export const TaskCompletedEvent = z.object({ type: z.literal('task-completed') }
|
|
|
29
33
|
export const RunTaskCommand = z.object({ type: z.literal('run-task') });
|
|
30
34
|
```
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
## What it does not flag
|
|
37
|
+
|
|
38
|
+
- Consts without a role suffix (`TaskSchema`), whatever their `type` literal says.
|
|
39
|
+
- Role-suffixed consts without a `type: z.literal(...)` property.
|
|
40
|
+
- Consts that are not exported: only `export const` declarations are checked.
|
|
33
41
|
|
|
34
42
|
## Options
|
|
35
43
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Every exported zod schema is a PascalCase const suffixed `Schema`, paired with a same-named inferred type.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
A contracts package is a shared spine. A uniform `FooSchema` + `Foo` pairing keeps the schema and
|
|
@@ -30,6 +34,12 @@ export const TaskSchema = z.object({ id: z.string() });
|
|
|
30
34
|
export type Task = z.infer<typeof TaskSchema>;
|
|
31
35
|
```
|
|
32
36
|
|
|
37
|
+
## What it does not flag
|
|
38
|
+
|
|
39
|
+
- Exported consts whose initializer is not rooted at `z` (`export const MAX = 10`).
|
|
40
|
+
- Zod schemas that are not exported.
|
|
41
|
+
- Consts ending in one of the configured `roleSuffixes` (see Options).
|
|
42
|
+
|
|
33
43
|
## Options
|
|
34
44
|
|
|
35
45
|
| Option | Type | Default | Meaning |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "ESLint rules for shared contract, config, error-handling, and money-precision conventions (zod schema naming, wire discriminants, no-direct-process-env, decimal money).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"url": "git+https://github.com/noctcore/eslint-plugins.git",
|
|
39
39
|
"directory": "packages/eslint-plugin-contracts"
|
|
40
40
|
},
|
|
41
|
-
"homepage": "https://github.
|
|
41
|
+
"homepage": "https://noctcore.github.io/eslint-plugins/packages/contracts/",
|
|
42
42
|
"bugs": "https://github.com/noctcore/eslint-plugins/issues",
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"test": "vitest run"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@noctcore/eslint-utils": "^0.1.
|
|
49
|
+
"@noctcore/eslint-utils": "^0.1.2",
|
|
50
50
|
"@typescript-eslint/utils": "^8.61.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
@@ -55,11 +55,11 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@noctcore/eslint-test-utils": "workspace:*",
|
|
58
|
-
"@types/node": "^22.
|
|
59
|
-
"@typescript-eslint/parser": "^8.
|
|
60
|
-
"@typescript-eslint/rule-tester": "^8.
|
|
58
|
+
"@types/node": "^22.20.3",
|
|
59
|
+
"@typescript-eslint/parser": "^8.70.0",
|
|
60
|
+
"@typescript-eslint/rule-tester": "^8.70.0",
|
|
61
61
|
"tsup": "^8.5.1",
|
|
62
62
|
"typescript": "^5.6.0",
|
|
63
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^5"
|
|
64
64
|
}
|
|
65
65
|
}
|