@noctcore/eslint-plugin-contracts 0.3.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.
@@ -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.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",