@noctcore/eslint-plugin-contracts 0.2.0 → 0.4.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 +5 -2
- package/dist/index.cjs +1410 -84
- package/dist/index.d.cts +94 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +1410 -84
- package/docs/rules/fetch-must-check-ok.md +83 -0
- package/docs/rules/schema-enum-field-consistency.md +75 -0
- package/docs/rules/translation-key-exists.md +155 -0
- package/package.json +2 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# `noctcore-contracts/fetch-must-check-ok`
|
|
2
|
+
|
|
3
|
+
> A fetch response must be checked with `.ok` or a status comparison before `.json()` parses its body.
|
|
4
|
+
|
|
5
|
+
Ported from tsforge's `typescript-core/fetch-must-check-ok` (MIT).
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
`fetch` rejects only on a network failure. A 4xx or 5xx resolves normally, and `.json()` then parses
|
|
10
|
+
the error body as if it were data. Wrapping the call in `try` does not help on its own: the code
|
|
11
|
+
fails closed, but every bad response surfaces as a parse or schema error that says nothing about the
|
|
12
|
+
status the server actually sent. Check the response first, and the failure is named where it happens.
|
|
13
|
+
|
|
14
|
+
## What it flags
|
|
15
|
+
|
|
16
|
+
A `.json()` read on a response bound from a configured fetch callee when no check governs it.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// ✗ no check at all
|
|
20
|
+
export async function loadUser(id: string) {
|
|
21
|
+
const res = await fetch(`/api/users/${id}`);
|
|
22
|
+
return res.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ✗ the check comes after the body is already parsed
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
if (!res.ok) throw new Error('failed');
|
|
28
|
+
|
|
29
|
+
// ✗ a single-code guard lets every other error through
|
|
30
|
+
if (res.status === 404) return null;
|
|
31
|
+
return res.json();
|
|
32
|
+
|
|
33
|
+
// ✗ `||` runs the parse exactly on the failure path
|
|
34
|
+
return res.ok || res.json();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// ✓ guard clause
|
|
39
|
+
export async function loadUser(id: string) {
|
|
40
|
+
const res = await fetch(`/api/users/${id}`);
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(`user request failed: ${res.status}`);
|
|
43
|
+
}
|
|
44
|
+
return res.json();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ✓ the parse sits in the branch the check permits
|
|
48
|
+
return res.ok ? res.json() : null;
|
|
49
|
+
|
|
50
|
+
// ✓ a status split at the success/error boundary, or a switch on success codes
|
|
51
|
+
if (res.status >= 400) return null;
|
|
52
|
+
switch (res.status) { case 200: case 201: return res.json(); default: return null; }
|
|
53
|
+
|
|
54
|
+
// ✓ an assertion helper (`assert`, `invariant`, `ensure*`, `expect*`)
|
|
55
|
+
invariant(res.ok, 'user request failed');
|
|
56
|
+
return res.json();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Three response shapes are tracked: `const res = await fetch(...)` then `res.json()` in the same block,
|
|
60
|
+
`fetch(...).then((res) => ...)`, and the unbound `(await fetch(...)).json()`, which is always reported.
|
|
61
|
+
Aliases work (`const ok = res.ok; if (!ok) throw ...`). A bare `if (res.status)` or
|
|
62
|
+
`typeof res.status === 'number'` is not a check: both are true for a 500.
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
|
+
|
|
66
|
+
| Option | Type | Default | Meaning |
|
|
67
|
+
| --- | --- | --- | --- |
|
|
68
|
+
| `fetchFunctions` | `string[]` | `['fetch']` | Callees that return a `Response`: a bare name or a dotted path. |
|
|
69
|
+
|
|
70
|
+
List every name you want tracked, including `fetch` itself:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
'noctcore-contracts/fetch-must-check-ok': ['error', {
|
|
74
|
+
fetchFunctions: ['fetch', 'globalThis.fetch', 'fetchWithRetry'],
|
|
75
|
+
}]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Limits
|
|
79
|
+
|
|
80
|
+
Purely syntactic, no type information. A response assigned later (`let res; res = await fetch(...)`),
|
|
81
|
+
passed to another function, or returned from a wrapper that is not in `fetchFunctions` is not tracked.
|
|
82
|
+
A nested function that reuses the response's name is treated as the same binding. Clients whose
|
|
83
|
+
`.json()` already throws on a bad status (ky, for example) do not belong in `fetchFunctions`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# `noctcore-contracts/schema-enum-field-consistency`
|
|
2
|
+
|
|
3
|
+
> A field that is an enum in one zod object schema must not be `z.string()` in another schema of the
|
|
4
|
+
> same module.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
When schemas double as wire types (tRPC procedures, a shared contract package), the output schema is
|
|
9
|
+
the type the client imports. If a field is an enum on the inputs but `z.string()` on the output, the
|
|
10
|
+
widened type leaks `string` to every consumer, which then narrows or casts by hand. Each schema is
|
|
11
|
+
correct on its own, so neither the compiler nor review catches it.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
Per file, purely syntactic, no type information. The rule collects every property of every
|
|
16
|
+
`z.object` / `z.strictObject` / `z.looseObject` and every `.extend` / `.safeExtend` shape, unwraps
|
|
17
|
+
`.optional()`, `.nullable()`, `.nullish()`, `.default()`, `.prefault()`, `.catch()`, `.describe()`,
|
|
18
|
+
`.meta()` and `.readonly()`, and classifies each value:
|
|
19
|
+
|
|
20
|
+
- **enum**: `z.enum(...)`, `z.nativeEnum(...)`, a `z.union` of only `z.literal`s, a multi-value
|
|
21
|
+
`z.literal([...])`, `.extract()` / `.exclude()` of an enum, or an identifier whose same-file
|
|
22
|
+
declaration is one of those (or an import matching `enumIdentifierPattern`)
|
|
23
|
+
- **string**: a chain rooted at `z.string()` with no `.pipe()` or `.transform()`
|
|
24
|
+
- anything else is ignored, including `z.union([z.string(), z.null()])`
|
|
25
|
+
|
|
26
|
+
Every **string** occurrence of a key that is **enum** elsewhere in the file is reported. No autofix:
|
|
27
|
+
the right fix may be a data migration (the stored column was free text), not a schema edit.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// ✗ the output widens what both inputs narrow
|
|
31
|
+
export const statusSchema = z.enum(['OPEN', 'CLOSED']);
|
|
32
|
+
|
|
33
|
+
export const ticketCreateInput = z.object({ status: statusSchema.default('OPEN') });
|
|
34
|
+
export const ticketUpdateInput = z.object({ status: statusSchema.optional() });
|
|
35
|
+
export const ticketOutput = z.object({
|
|
36
|
+
id: z.string(),
|
|
37
|
+
status: z.string().nullable(),
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// ✓ the output reuses the enum
|
|
43
|
+
export const ticketOutput = z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
status: statusSchema.nullable(),
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Options
|
|
50
|
+
|
|
51
|
+
| Option | Type | Default | Meaning |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| `zodIdentifiers` | `string[]` | `['z']` | Names zod is imported under. |
|
|
54
|
+
| `ignoreFields` | `string[]` | `[]` | Keys that genuinely mean different things in two schemas. |
|
|
55
|
+
| `enumIdentifierPattern` | `string` (regex source) | unset | An imported identifier counts as an enum only when its name matches. |
|
|
56
|
+
|
|
57
|
+
Imported identifiers are **not** assumed to be enums by default, so `email: emailSchema` (a string
|
|
58
|
+
schema from another module) is not flagged against `email: z.string()`. If your project names its
|
|
59
|
+
enum schemas consistently, opt them in:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
'noctcore-contracts/schema-enum-field-consistency': ['error', {
|
|
63
|
+
zodIdentifiers: ['z'],
|
|
64
|
+
enumIdentifierPattern: '(Status|Kind|Role)Schema$',
|
|
65
|
+
ignoreFields: ['type'],
|
|
66
|
+
}]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Scope the rule to your schema modules with the config block's `files` (for example
|
|
70
|
+
`files: ['src/schemas/**/*.ts']`); the rule itself assumes no layout.
|
|
71
|
+
|
|
72
|
+
## When not to use it
|
|
73
|
+
|
|
74
|
+
If a module intentionally keeps free-text and enum variants of the same key (a legacy import format
|
|
75
|
+
beside the validated one), list that key in `ignoreFields` rather than turning the rule off.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# `noctcore-contracts/translation-key-exists`
|
|
2
|
+
|
|
3
|
+
> Every static translation key must exist in the catalog of the namespace in scope.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
i18next does not fail on a missing key. It renders the key itself (`admin.portalAccounts.revokeTitle`)
|
|
8
|
+
or silently falls back to another language, and nothing at build time says so. A renamed or deleted
|
|
9
|
+
catalog key, a typo, or a key looked up in the wrong namespace is a UI regression that only a human
|
|
10
|
+
reading the screen will catch. This rule resolves every static key the way i18next would and reports
|
|
11
|
+
the ones that cannot resolve.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
A static key that no configured catalog of the namespace in scope contains:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
// catalogs: common = { actions: { save, cancel } }, portal = { tasks: { title } }
|
|
19
|
+
|
|
20
|
+
const { t } = useTranslation(); // default namespace: common
|
|
21
|
+
t('actions.sav'); // ✗ typo
|
|
22
|
+
t('common.cancel'); // ✗ the namespace is not a key segment; this renders "common.cancel"
|
|
23
|
+
t('actions.save'); // ✓
|
|
24
|
+
|
|
25
|
+
const { t: tp } = useTranslation('portal');
|
|
26
|
+
tp('actions.save'); // ✗ exists, but in `common`, not `portal`
|
|
27
|
+
tp('common:actions.save'); // ✓ explicit namespace
|
|
28
|
+
tp('actions.save', { ns: 'common' }); // ✓ explicit namespace
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
It also reports a namespace that has no catalog at all (`useTranslation('portl')`), and a catalog
|
|
32
|
+
that exists but cannot be parsed, once per file.
|
|
33
|
+
|
|
34
|
+
### Where namespaces come from
|
|
35
|
+
|
|
36
|
+
The rule reads them from syntax, per file, in the shapes i18next and react-i18next document:
|
|
37
|
+
|
|
38
|
+
| Shape | Namespace(s) searched |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `const { t } = useTranslation('ns')`, also `[t]`, `{ t: alias }`, `r.t` | `ns` |
|
|
41
|
+
| `useTranslation(['a', 'b'])` | `a`, then `b` |
|
|
42
|
+
| `useTranslation('ns', { keyPrefix: 'p' })` | `ns`, key prefixed with `p.` |
|
|
43
|
+
| `const t = i18n.getFixedT(lng, 'ns', 'p')` | `ns`, key prefixed with `p.` |
|
|
44
|
+
| `i18n.t(...)`, `i18next.t(...)`, an imported or global `t` | the default namespace |
|
|
45
|
+
| `function f(t: TFunction<'ns'>)` | `ns` |
|
|
46
|
+
| `t('ns:key')` | `ns` (wins over everything) |
|
|
47
|
+
| `t('key', { ns: 'ns' })` | `ns` (wins over the binding) |
|
|
48
|
+
| `<Trans i18nKey="key" ns="ns" t={t} />` | `ns`, else the bound `t`, else the default |
|
|
49
|
+
|
|
50
|
+
A namespace argument may be a string literal, an array of literals, a same-file `const`, a name in
|
|
51
|
+
`namespaceIdentifiers`, or, under typed linting, any identifier whose type is one string literal.
|
|
52
|
+
|
|
53
|
+
### Plurals, context, objects
|
|
54
|
+
|
|
55
|
+
Plural forms (`key_one`, `key_few`, `key_ordinal_other`) answer for `key` only when the call passes
|
|
56
|
+
`count`, and context variants (`key_male`) only when it passes `context`, because without them
|
|
57
|
+
i18next looks up the bare key and misses. A subtree or array answers only with `returnObjects`.
|
|
58
|
+
|
|
59
|
+
### What it never reports
|
|
60
|
+
|
|
61
|
+
Anything it cannot resolve statically: a variable key (`t(someKey)`), a template key
|
|
62
|
+
(`` t(`status.${s}`) ``), a key computed by a helper, a namespace held in an identifier it cannot
|
|
63
|
+
resolve (an import not listed in `namespaceIdentifiers`), an options bag it cannot read
|
|
64
|
+
(`t('k', opts)`, `{ ...opts }`, `{ ns: someNs }`), and a `t` parameter with no `TFunction` type. The
|
|
65
|
+
rule stays silent on those rather than guessing.
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
| Option | Type | Default | Meaning |
|
|
70
|
+
| --- | --- | --- | --- |
|
|
71
|
+
| `catalogs` | `{ file, namespace?, keyPath? }[]` | `[]` | Where each namespace's keys live. Empty = rule is inert. See below. |
|
|
72
|
+
| `defaultNamespace` | `string` | `'translation'` | i18next `defaultNS`: what `useTranslation()` and `i18n.t` resolve to. |
|
|
73
|
+
| `fallbackNamespaces` | `string[]` | `[]` | i18next `fallbackNS`: searched after the bound namespaces. |
|
|
74
|
+
| `hooks` | `string[]` | `['useTranslation']` | Hooks returning a namespace-bound `t`. |
|
|
75
|
+
| `instances` | `string[]` | `['i18n', 'i18next']` | i18next instances: `<instance>.t(...)`, `<instance>.getFixedT(...)`. |
|
|
76
|
+
| `functions` | `string[]` | `['t']` | Bare translation functions bound to the default namespace when imported (by that name) or global. An untyped parameter with one of these names is skipped. |
|
|
77
|
+
| `typeNames` | `string[]` | `['TFunction']` | Parameter types whose first type argument is the namespace, second the key prefix. |
|
|
78
|
+
| `transComponents` | `string[]` | `['Trans']` | JSX components taking `i18nKey` / `ns` / `t` / `count` / `context` props. |
|
|
79
|
+
| `namespaceIdentifiers` | `Record<string, string>` | `{}` | Imported identifiers that hold a namespace name, e.g. `{ HELP_NS: 'help' }`. |
|
|
80
|
+
| `nsSeparator` | `string \| false` | `':'` | i18next `nsSeparator`. `false` turns off `ns:key` parsing. |
|
|
81
|
+
| `keySeparator` | `string \| false` | `'.'` | i18next `keySeparator`. `false` means flat catalogs. |
|
|
82
|
+
| `pluralSeparator` | `string` | `'_'` | i18next `pluralSeparator`. |
|
|
83
|
+
| `contextSeparator` | `string` | `'_'` | i18next `contextSeparator`. |
|
|
84
|
+
| `dynamicKeys` | `'ignore' \| 'check-prefix'` | `'ignore'` | `check-prefix` also requires a template key's static head (`` `status.${s}` `` → `status.`) to be the start of at least one key. Sound, since nothing else can match, but opt-in. |
|
|
85
|
+
|
|
86
|
+
Relative paths resolve against the ESLint working directory. Match the separators and default
|
|
87
|
+
namespace to your `i18next.init` options.
|
|
88
|
+
|
|
89
|
+
### `catalogs`
|
|
90
|
+
|
|
91
|
+
Each entry is a JSON file, or a subtree of one, holding a namespace's keys. Use the one reference
|
|
92
|
+
language whose keys are the source of truth (catalog parity between languages is a different check).
|
|
93
|
+
|
|
94
|
+
| Layout | Entry |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| one file per namespace | `{ file: 'public/locales/en/{ns}.json' }` |
|
|
97
|
+
| one file, namespaces at the top level | `{ file: 'src/i18n/en.json', keyPath: '{ns}' }` |
|
|
98
|
+
| a fixed file for one namespace | `{ file: 'src/i18n/common.en.json', namespace: 'common' }` |
|
|
99
|
+
| single-namespace app | `{ file: 'src/i18n/en.json' }` (supplies `defaultNamespace`) |
|
|
100
|
+
|
|
101
|
+
`{ns}` is replaced by the namespace being resolved, in `file` and in `keyPath` (a dot path). A
|
|
102
|
+
templated entry whose file or subtree does not exist simply does not supply that namespace; a fixed
|
|
103
|
+
entry that cannot be read is reported. Entries are unioned, so several may supply one namespace.
|
|
104
|
+
Catalogs are read on demand, cached for the process, and re-read when their modification time
|
|
105
|
+
changes. A namespace that would escape its directory (`..`, a path separator) is never substituted.
|
|
106
|
+
|
|
107
|
+
### Worked example
|
|
108
|
+
|
|
109
|
+
A Polish-first React app with two shared namespaces (`common`, `errors`) stored as the top-level
|
|
110
|
+
keys of `apps/web/src/lib/i18n/locales/pl.json`, one namespace per feature at
|
|
111
|
+
`apps/web/src/features/<ns>/locales/pl.json`, `defaultNS: 'common'`, and one namespace name
|
|
112
|
+
exported as a constant from another module:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
import contracts from '@noctcore/eslint-plugin-contracts';
|
|
116
|
+
|
|
117
|
+
export default [
|
|
118
|
+
{
|
|
119
|
+
files: ['apps/web/src/**/*.{ts,tsx}'],
|
|
120
|
+
plugins: { 'noctcore-contracts': contracts },
|
|
121
|
+
rules: {
|
|
122
|
+
'noctcore-contracts/translation-key-exists': [
|
|
123
|
+
'error',
|
|
124
|
+
{
|
|
125
|
+
catalogs: [
|
|
126
|
+
{ file: 'apps/web/src/lib/i18n/locales/pl.json', keyPath: '{ns}' },
|
|
127
|
+
{ file: 'apps/web/src/features/{ns}/locales/pl.json' },
|
|
128
|
+
],
|
|
129
|
+
defaultNamespace: 'common',
|
|
130
|
+
namespaceIdentifiers: { HELP_NS: 'help' },
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
With that configuration the rule resolved 1,500 static keys across 1,110 files of that app and
|
|
139
|
+
found one real bug (`t('common.cancel')` in the default namespace, which renders the raw key). A
|
|
140
|
+
namespace registered at runtime only inside a test (`registerFeatureNamespace('late-arrival', ...)`)
|
|
141
|
+
is reported as unknown; exclude test files or add a catalog entry for it.
|
|
142
|
+
|
|
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
|
+
## When not to use it
|
|
153
|
+
|
|
154
|
+
If your keys are mostly computed, or your catalogs are not JSON files on disk (fetched from a TMS at
|
|
155
|
+
runtime, generated at build time), leave this rule off.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
61
61
|
"tsup": "^8.5.1",
|
|
62
62
|
"typescript": "^5.6.0",
|
|
63
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^4"
|
|
64
64
|
}
|
|
65
65
|
}
|