@noctcore/eslint-plugin-contracts 0.4.0 → 0.6.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/dist/index.cjs CHANGED
@@ -30,9 +30,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ TRANSLATION_DEFAULTS: () => TRANSLATION_DEFAULTS,
34
+ catalogHasKey: () => catalogHasKey,
35
+ catalogHasPrefix: () => catalogHasPrefix,
36
+ catalogsForNamespace: () => catalogsForNamespace,
33
37
  configs: () => configs,
38
+ createTranslationVisitor: () => createTranslationVisitor,
34
39
  default: () => index_default,
35
- rules: () => rules
40
+ rules: () => rules,
41
+ translationSettingsOf: () => translationSettingsOf
36
42
  });
37
43
  module.exports = __toCommonJS(index_exports);
38
44
 
@@ -633,6 +639,7 @@ var DEFAULT_FIELD_PATTERNS = [
633
639
  "balance"
634
640
  ];
635
641
  var DEFAULT_ALLOWED_FILES = [];
642
+ var DEFAULT_MINOR_UNIT_PATTERNS = [];
636
643
  var optionSchema3 = {
637
644
  type: "object",
638
645
  additionalProperties: false,
@@ -648,6 +655,11 @@ var optionSchema3 = {
648
655
  type: "array",
649
656
  items: { type: "string" },
650
657
  uniqueItems: true
658
+ },
659
+ minorUnitPatterns: {
660
+ type: "array",
661
+ items: { type: "string", minLength: 1 },
662
+ uniqueItems: true
651
663
  }
652
664
  }
653
665
  };
@@ -686,7 +698,8 @@ var moneyMustBeDecimalRule = createRule({
686
698
  {
687
699
  decimalType: DEFAULT_DECIMAL_TYPE,
688
700
  fieldPatterns: [...DEFAULT_FIELD_PATTERNS],
689
- allowedFiles: []
701
+ allowedFiles: [],
702
+ minorUnitPatterns: []
690
703
  }
691
704
  ],
692
705
  create(context, [options]) {
@@ -697,6 +710,11 @@ var moneyMustBeDecimalRule = createRule({
697
710
  }
698
711
  const fieldPatterns = options.fieldPatterns ?? DEFAULT_FIELD_PATTERNS;
699
712
  const moneyPattern = new RegExp(`(${fieldPatterns.join("|")})`, "i");
713
+ const minorUnitPatterns = options.minorUnitPatterns ?? DEFAULT_MINOR_UNIT_PATTERNS;
714
+ const minorUnitPattern = minorUnitPatterns.length > 0 ? new RegExp(`(${minorUnitPatterns.join("|")})`, "i") : null;
715
+ function isDecimalMoneyName(name) {
716
+ return moneyPattern.test(name) && !(minorUnitPattern?.test(name) ?? false);
717
+ }
700
718
  function report(node) {
701
719
  context.report({ node, messageId: "moneyMustBeDecimal", data: { decimalType } });
702
720
  }
@@ -707,7 +725,7 @@ var moneyMustBeDecimalRule = createRule({
707
725
  return;
708
726
  }
709
727
  const name = staticName(node.key);
710
- if (name !== void 0 && moneyPattern.test(name) && isNumberAnnotation(node.typeAnnotation)) {
728
+ if (name !== void 0 && isDecimalMoneyName(name) && isNumberAnnotation(node.typeAnnotation)) {
711
729
  report(node);
712
730
  }
713
731
  },
@@ -717,7 +735,7 @@ var moneyMustBeDecimalRule = createRule({
717
735
  return;
718
736
  }
719
737
  const name = node.id.name;
720
- if (moneyPattern.test(name) && isNumberAnnotation(node.id.typeAnnotation)) {
738
+ if (isDecimalMoneyName(name) && isNumberAnnotation(node.id.typeAnnotation)) {
721
739
  report(node);
722
740
  }
723
741
  }
@@ -2302,6 +2320,12 @@ var configs = plugin.configs;
2302
2320
  var index_default = plugin;
2303
2321
  // Annotate the CommonJS export names for ESM import in node:
2304
2322
  0 && (module.exports = {
2323
+ TRANSLATION_DEFAULTS,
2324
+ catalogHasKey,
2325
+ catalogHasPrefix,
2326
+ catalogsForNamespace,
2305
2327
  configs,
2306
- rules
2328
+ createTranslationVisitor,
2329
+ rules,
2330
+ translationSettingsOf
2307
2331
  });
package/dist/index.d.cts CHANGED
@@ -1,60 +1,5 @@
1
1
  import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
-
3
- /**
4
- * Translation catalog loading for the i18n rules.
5
- *
6
- * A catalog is a JSON file (or a subtree of one) that holds the keys of ONE
7
- * namespace. Projects lay catalogs out in a handful of shapes, and a
8
- * `CatalogSource` describes each of them without the rule knowing any project:
9
- *
10
- * one file per namespace `{ file: 'locales/en/{ns}.json' }`
11
- * one file, ns at the top `{ file: 'locales/en.json', keyPath: '{ns}' }`
12
- * a fixed file for one ns `{ file: 'src/i18n/en.json', namespace: 'common' }`
13
- * single-namespace app `{ file: 'src/i18n/en.json' }` (the default namespace)
14
- *
15
- * `{ns}` is substituted with the namespace being resolved. A templated source
16
- * whose file or subtree does not exist simply does not supply that namespace;
17
- * a FIXED source that cannot be read is a configuration error and is surfaced.
18
- */
19
- interface CatalogSource {
20
- /** JSON catalog path, relative to the ESLint cwd. May contain `{ns}`. */
21
- readonly file: string;
22
- /** Namespace a fixed (non-templated) source supplies. Defaults to the default namespace. */
23
- readonly namespace?: string;
24
- /** Dot-separated subtree inside the file holding the namespace's keys. May contain `{ns}`. */
25
- readonly keyPath?: string;
26
- }
27
-
28
- interface TranslationKeyExistsOptions {
29
- /** Where each namespace's catalog lives. Empty = rule is inert. */
30
- readonly catalogs?: readonly CatalogSource[];
31
- /** The namespace an unqualified `useTranslation()` / `i18n.t` resolves to (i18next `defaultNS`). */
32
- readonly defaultNamespace?: string;
33
- /** Namespaces searched after the bound ones (i18next `fallbackNS`). */
34
- readonly fallbackNamespaces?: readonly string[];
35
- /** Hooks returning a namespace-bound `t` (`useTranslation`). */
36
- readonly hooks?: readonly string[];
37
- /** i18next instance identifiers: `<instance>.t(...)`, `<instance>.getFixedT(...)`. */
38
- readonly instances?: readonly string[];
39
- /** Free translation functions bound to the default namespace when imported or global (`t`). */
40
- readonly functions?: readonly string[];
41
- /** Type names whose first type argument names a parameter's namespace (`TFunction<'ns'>`). */
42
- readonly typeNames?: readonly string[];
43
- /** JSX components taking an `i18nKey` prop (`Trans`). */
44
- readonly transComponents?: readonly string[];
45
- /** Identifiers holding a namespace name that live in another module (`{ HELP_NS: 'help' }`). */
46
- readonly namespaceIdentifiers?: Readonly<Record<string, string>>;
47
- /** i18next `nsSeparator`; `false` disables `ns:key` parsing. */
48
- readonly nsSeparator?: string | false;
49
- /** i18next `keySeparator`; `false` means flat catalogs. */
50
- readonly keySeparator?: string | false;
51
- /** i18next `pluralSeparator`. */
52
- readonly pluralSeparator?: string;
53
- /** i18next `contextSeparator`. */
54
- readonly contextSeparator?: string;
55
- /** `ignore` stays silent on template keys; `check-prefix` requires their static head to exist. */
56
- readonly dynamicKeys?: 'ignore' | 'check-prefix';
57
- }
2
+ import { TSESTree, TSESLint } from '@typescript-eslint/utils';
58
3
 
59
4
  interface FetchMustCheckOkOptions {
60
5
  /**
@@ -106,6 +51,12 @@ interface MoneyMustBeDecimalOptions {
106
51
  readonly fieldPatterns?: readonly string[];
107
52
  /** Path-suffix allowlist of files to skip entirely. */
108
53
  readonly allowedFiles?: readonly string[];
54
+ /**
55
+ * Regex fragments (case-insensitive) naming fields that hold an integer count
56
+ * of minor units (cents), where `number` is the correct type. A money-named
57
+ * field matching one of these is not reported. Empty by default.
58
+ */
59
+ readonly minorUnitPatterns?: readonly string[];
109
60
  }
110
61
 
111
62
  interface NoDirectProcessEnvOptions {
@@ -127,6 +78,173 @@ interface ZodSchemaNamingOptions {
127
78
  readonly roleSuffixes?: readonly string[];
128
79
  }
129
80
 
81
+ /**
82
+ * Translation catalog loading for the i18n rules.
83
+ *
84
+ * A catalog is a JSON file (or a subtree of one) that holds the keys of ONE
85
+ * namespace. Projects lay catalogs out in a handful of shapes, and a
86
+ * `CatalogSource` describes each of them without the rule knowing any project:
87
+ *
88
+ * one file per namespace `{ file: 'locales/en/{ns}.json' }`
89
+ * one file, ns at the top `{ file: 'locales/en.json', keyPath: '{ns}' }`
90
+ * a fixed file for one ns `{ file: 'src/i18n/en.json', namespace: 'common' }`
91
+ * single-namespace app `{ file: 'src/i18n/en.json' }` (the default namespace)
92
+ *
93
+ * `{ns}` is substituted with the namespace being resolved. A templated source
94
+ * whose file or subtree does not exist simply does not supply that namespace;
95
+ * a FIXED source that cannot be read is a configuration error and is surfaced.
96
+ */
97
+ interface CatalogSource {
98
+ /** JSON catalog path, relative to the ESLint cwd. May contain `{ns}`. */
99
+ readonly file: string;
100
+ /** Namespace a fixed (non-templated) source supplies. Defaults to the default namespace. */
101
+ readonly namespace?: string;
102
+ /** Dot-separated subtree inside the file holding the namespace's keys. May contain `{ns}`. */
103
+ readonly keyPath?: string;
104
+ }
105
+ /** The flattened key space of one namespace catalog. */
106
+ interface Catalog {
107
+ /** Where the keys came from, for messages: `file` or `file#keyPath`. */
108
+ readonly label: string;
109
+ /** Every leaf key (a string / number / boolean value), joined by the key separator. */
110
+ readonly leaves: ReadonlySet<string>;
111
+ /** Every non-leaf key (an object or array), joined by the key separator. */
112
+ readonly branches: ReadonlySet<string>;
113
+ }
114
+ /** Outcome of resolving one namespace against every configured source. */
115
+ interface NamespaceCatalogs {
116
+ readonly catalogs: readonly Catalog[];
117
+ /** Fixed sources that should supply this namespace but could not be loaded. */
118
+ readonly errors: readonly string[];
119
+ }
120
+ /** Every catalog the configured sources supply for `namespace`, in source order. */
121
+ declare function catalogsForNamespace(namespace: string, sources: readonly CatalogSource[], settings: {
122
+ readonly cwd: string;
123
+ readonly defaultNamespace: string;
124
+ readonly keySeparator: string | false;
125
+ }): NamespaceCatalogs;
126
+ interface KeyLookup {
127
+ /** Plural forms may answer for the key (the call passes, or may pass, `count`). */
128
+ readonly plural: boolean;
129
+ /** Context variants may answer for the key (the call passes, or may pass, `context`). */
130
+ readonly context: boolean;
131
+ /** The call asks for an object (`returnObjects`), so a branch answers too. */
132
+ readonly returnObjects: boolean;
133
+ readonly pluralSeparator: string;
134
+ readonly contextSeparator: string;
135
+ }
136
+ /** Does `key` resolve in `catalog` the way i18next would look it up? */
137
+ declare function catalogHasKey(catalog: Catalog, key: string, lookup: KeyLookup): boolean;
138
+ /** Does any key in `catalog` start with `prefix`? (the `dynamicKeys: 'check-prefix'` probe) */
139
+ declare function catalogHasPrefix(catalog: Catalog, prefix: string): boolean;
140
+
141
+ /**
142
+ * Static discovery of translation-key usages, i18next / react-i18next style.
143
+ *
144
+ * The visitor answers one question per call site: "which key, in which
145
+ * namespace(s)?" It never guesses. Every shape it cannot pin down statically
146
+ * (a variable key, a namespace held in a variable it cannot resolve, an opaque
147
+ * options bag) comes back as `dynamic` / `unresolved` so the caller can stay
148
+ * silent on it, the way sibling rules stay silent on spreads.
149
+ *
150
+ * Namespace sources it understands, all by syntax, all per file:
151
+ * `const { t } = useTranslation('ns', { keyPrefix })` (also `[t]`, `r.t`, aliases)
152
+ * `const t = i18n.getFixedT(lng, 'ns', keyPrefix)`
153
+ * `i18n.t(...)` / `i18next.t(...)` (default namespace)
154
+ * `function f(t: TFunction<'ns'>)` (typed parameter)
155
+ * `import { t } from 'i18next'` or a global `t` (default namespace)
156
+ * per call: `t('ns:key')`, `t('key', { ns: 'ns' })`
157
+ * JSX: `<Trans i18nKey="key" ns="ns" t={t} />`
158
+ * A namespace argument may be a literal, an array of literals, a same-file
159
+ * `const`, a name mapped in `namespaceIdentifiers`, or (under typed linting)
160
+ * any identifier whose type is a single string literal.
161
+ */
162
+ interface TranslationSettings {
163
+ readonly hooks: ReadonlySet<string>;
164
+ readonly instances: ReadonlySet<string>;
165
+ readonly functions: ReadonlySet<string>;
166
+ readonly typeNames: ReadonlySet<string>;
167
+ readonly transComponents: ReadonlySet<string>;
168
+ readonly namespaceIdentifiers: Readonly<Record<string, string>>;
169
+ readonly defaultNamespace: string;
170
+ readonly nsSeparator: string | false;
171
+ readonly keySeparator: string | false;
172
+ }
173
+ type TranslationUsage = {
174
+ /** Every key is static: the call is checkable. */
175
+ readonly kind: 'key';
176
+ readonly node: TSESTree.Node;
177
+ /** Namespaces i18next would search, in order. */
178
+ readonly namespaces: readonly string[];
179
+ /** Candidate keys (more than one for `t(['a', 'b'])`); any one resolving is enough. */
180
+ readonly keys: readonly string[];
181
+ readonly plural: boolean;
182
+ readonly context: boolean;
183
+ readonly returnObjects: boolean;
184
+ } | {
185
+ /** A template key with a static head: only its prefix is known. */
186
+ readonly kind: 'prefix';
187
+ readonly node: TSESTree.Node;
188
+ readonly namespaces: readonly string[];
189
+ readonly prefix: string;
190
+ } | {
191
+ /** The key itself is not static (`t(someVariable)`, `` t(`${x}`) ``). */
192
+ readonly kind: 'dynamic';
193
+ readonly node: TSESTree.Node;
194
+ } | {
195
+ /** A translation call whose namespace or options cannot be resolved statically. */
196
+ readonly kind: 'unresolved';
197
+ readonly node: TSESTree.Node;
198
+ };
199
+ type Context = Readonly<TSESLint.RuleContext<string, readonly unknown[]>>;
200
+ declare function createTranslationVisitor(context: Context, settings: TranslationSettings, onUsage: (usage: TranslationUsage) => void): TSESLint.RuleListener;
201
+
202
+ interface TranslationKeyExistsOptions {
203
+ /** Where each namespace's catalog lives. Empty = rule is inert. */
204
+ readonly catalogs?: readonly CatalogSource[];
205
+ /** The namespace an unqualified `useTranslation()` / `i18n.t` resolves to (i18next `defaultNS`). */
206
+ readonly defaultNamespace?: string;
207
+ /** Namespaces searched after the bound ones (i18next `fallbackNS`). */
208
+ readonly fallbackNamespaces?: readonly string[];
209
+ /** Hooks returning a namespace-bound `t` (`useTranslation`). */
210
+ readonly hooks?: readonly string[];
211
+ /** i18next instance identifiers: `<instance>.t(...)`, `<instance>.getFixedT(...)`. */
212
+ readonly instances?: readonly string[];
213
+ /** Free translation functions bound to the default namespace when imported or global (`t`). */
214
+ readonly functions?: readonly string[];
215
+ /** Type names whose first type argument names a parameter's namespace (`TFunction<'ns'>`). */
216
+ readonly typeNames?: readonly string[];
217
+ /** JSX components taking an `i18nKey` prop (`Trans`). */
218
+ readonly transComponents?: readonly string[];
219
+ /** Identifiers holding a namespace name that live in another module (`{ HELP_NS: 'help' }`). */
220
+ readonly namespaceIdentifiers?: Readonly<Record<string, string>>;
221
+ /** i18next `nsSeparator`; `false` disables `ns:key` parsing. */
222
+ readonly nsSeparator?: string | false;
223
+ /** i18next `keySeparator`; `false` means flat catalogs. */
224
+ readonly keySeparator?: string | false;
225
+ /** i18next `pluralSeparator`. */
226
+ readonly pluralSeparator?: string;
227
+ /** i18next `contextSeparator`. */
228
+ readonly contextSeparator?: string;
229
+ /** `ignore` stays silent on template keys; `check-prefix` requires their static head to exist. */
230
+ readonly dynamicKeys?: 'ignore' | 'check-prefix';
231
+ }
232
+ /** Defaults mirror i18next / react-i18next's own. */
233
+ declare const TRANSLATION_DEFAULTS: {
234
+ readonly defaultNamespace: "translation";
235
+ readonly hooks: readonly ["useTranslation"];
236
+ readonly instances: readonly ["i18n", "i18next"];
237
+ readonly functions: readonly ["t"];
238
+ readonly typeNames: readonly ["TFunction"];
239
+ readonly transComponents: readonly ["Trans"];
240
+ readonly nsSeparator: ":";
241
+ readonly keySeparator: ".";
242
+ readonly pluralSeparator: "_";
243
+ readonly contextSeparator: "_";
244
+ };
245
+ /** Normalise user options into the visitor's settings. */
246
+ declare function translationSettingsOf(options: TranslationKeyExistsOptions): TranslationSettings;
247
+
130
248
  /** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
131
249
  declare const rules: {
132
250
  'zod-schema-naming': _typescript_eslint_utils_ts_eslint.RuleModule<"schemaNaming" | "missingType", [ZodSchemaNamingOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
@@ -221,4 +339,4 @@ declare const plugin: {
221
339
 
222
340
  declare const configs: Record<string, unknown>;
223
341
 
224
- export { configs, plugin as default, rules };
342
+ export { type Catalog, type CatalogSource, type KeyLookup, type NamespaceCatalogs, TRANSLATION_DEFAULTS, type TranslationKeyExistsOptions, type TranslationSettings, type TranslationUsage, catalogHasKey, catalogHasPrefix, catalogsForNamespace, configs, createTranslationVisitor, plugin as default, rules, translationSettingsOf };
package/dist/index.d.ts CHANGED
@@ -1,60 +1,5 @@
1
1
  import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
2
-
3
- /**
4
- * Translation catalog loading for the i18n rules.
5
- *
6
- * A catalog is a JSON file (or a subtree of one) that holds the keys of ONE
7
- * namespace. Projects lay catalogs out in a handful of shapes, and a
8
- * `CatalogSource` describes each of them without the rule knowing any project:
9
- *
10
- * one file per namespace `{ file: 'locales/en/{ns}.json' }`
11
- * one file, ns at the top `{ file: 'locales/en.json', keyPath: '{ns}' }`
12
- * a fixed file for one ns `{ file: 'src/i18n/en.json', namespace: 'common' }`
13
- * single-namespace app `{ file: 'src/i18n/en.json' }` (the default namespace)
14
- *
15
- * `{ns}` is substituted with the namespace being resolved. A templated source
16
- * whose file or subtree does not exist simply does not supply that namespace;
17
- * a FIXED source that cannot be read is a configuration error and is surfaced.
18
- */
19
- interface CatalogSource {
20
- /** JSON catalog path, relative to the ESLint cwd. May contain `{ns}`. */
21
- readonly file: string;
22
- /** Namespace a fixed (non-templated) source supplies. Defaults to the default namespace. */
23
- readonly namespace?: string;
24
- /** Dot-separated subtree inside the file holding the namespace's keys. May contain `{ns}`. */
25
- readonly keyPath?: string;
26
- }
27
-
28
- interface TranslationKeyExistsOptions {
29
- /** Where each namespace's catalog lives. Empty = rule is inert. */
30
- readonly catalogs?: readonly CatalogSource[];
31
- /** The namespace an unqualified `useTranslation()` / `i18n.t` resolves to (i18next `defaultNS`). */
32
- readonly defaultNamespace?: string;
33
- /** Namespaces searched after the bound ones (i18next `fallbackNS`). */
34
- readonly fallbackNamespaces?: readonly string[];
35
- /** Hooks returning a namespace-bound `t` (`useTranslation`). */
36
- readonly hooks?: readonly string[];
37
- /** i18next instance identifiers: `<instance>.t(...)`, `<instance>.getFixedT(...)`. */
38
- readonly instances?: readonly string[];
39
- /** Free translation functions bound to the default namespace when imported or global (`t`). */
40
- readonly functions?: readonly string[];
41
- /** Type names whose first type argument names a parameter's namespace (`TFunction<'ns'>`). */
42
- readonly typeNames?: readonly string[];
43
- /** JSX components taking an `i18nKey` prop (`Trans`). */
44
- readonly transComponents?: readonly string[];
45
- /** Identifiers holding a namespace name that live in another module (`{ HELP_NS: 'help' }`). */
46
- readonly namespaceIdentifiers?: Readonly<Record<string, string>>;
47
- /** i18next `nsSeparator`; `false` disables `ns:key` parsing. */
48
- readonly nsSeparator?: string | false;
49
- /** i18next `keySeparator`; `false` means flat catalogs. */
50
- readonly keySeparator?: string | false;
51
- /** i18next `pluralSeparator`. */
52
- readonly pluralSeparator?: string;
53
- /** i18next `contextSeparator`. */
54
- readonly contextSeparator?: string;
55
- /** `ignore` stays silent on template keys; `check-prefix` requires their static head to exist. */
56
- readonly dynamicKeys?: 'ignore' | 'check-prefix';
57
- }
2
+ import { TSESTree, TSESLint } from '@typescript-eslint/utils';
58
3
 
59
4
  interface FetchMustCheckOkOptions {
60
5
  /**
@@ -106,6 +51,12 @@ interface MoneyMustBeDecimalOptions {
106
51
  readonly fieldPatterns?: readonly string[];
107
52
  /** Path-suffix allowlist of files to skip entirely. */
108
53
  readonly allowedFiles?: readonly string[];
54
+ /**
55
+ * Regex fragments (case-insensitive) naming fields that hold an integer count
56
+ * of minor units (cents), where `number` is the correct type. A money-named
57
+ * field matching one of these is not reported. Empty by default.
58
+ */
59
+ readonly minorUnitPatterns?: readonly string[];
109
60
  }
110
61
 
111
62
  interface NoDirectProcessEnvOptions {
@@ -127,6 +78,173 @@ interface ZodSchemaNamingOptions {
127
78
  readonly roleSuffixes?: readonly string[];
128
79
  }
129
80
 
81
+ /**
82
+ * Translation catalog loading for the i18n rules.
83
+ *
84
+ * A catalog is a JSON file (or a subtree of one) that holds the keys of ONE
85
+ * namespace. Projects lay catalogs out in a handful of shapes, and a
86
+ * `CatalogSource` describes each of them without the rule knowing any project:
87
+ *
88
+ * one file per namespace `{ file: 'locales/en/{ns}.json' }`
89
+ * one file, ns at the top `{ file: 'locales/en.json', keyPath: '{ns}' }`
90
+ * a fixed file for one ns `{ file: 'src/i18n/en.json', namespace: 'common' }`
91
+ * single-namespace app `{ file: 'src/i18n/en.json' }` (the default namespace)
92
+ *
93
+ * `{ns}` is substituted with the namespace being resolved. A templated source
94
+ * whose file or subtree does not exist simply does not supply that namespace;
95
+ * a FIXED source that cannot be read is a configuration error and is surfaced.
96
+ */
97
+ interface CatalogSource {
98
+ /** JSON catalog path, relative to the ESLint cwd. May contain `{ns}`. */
99
+ readonly file: string;
100
+ /** Namespace a fixed (non-templated) source supplies. Defaults to the default namespace. */
101
+ readonly namespace?: string;
102
+ /** Dot-separated subtree inside the file holding the namespace's keys. May contain `{ns}`. */
103
+ readonly keyPath?: string;
104
+ }
105
+ /** The flattened key space of one namespace catalog. */
106
+ interface Catalog {
107
+ /** Where the keys came from, for messages: `file` or `file#keyPath`. */
108
+ readonly label: string;
109
+ /** Every leaf key (a string / number / boolean value), joined by the key separator. */
110
+ readonly leaves: ReadonlySet<string>;
111
+ /** Every non-leaf key (an object or array), joined by the key separator. */
112
+ readonly branches: ReadonlySet<string>;
113
+ }
114
+ /** Outcome of resolving one namespace against every configured source. */
115
+ interface NamespaceCatalogs {
116
+ readonly catalogs: readonly Catalog[];
117
+ /** Fixed sources that should supply this namespace but could not be loaded. */
118
+ readonly errors: readonly string[];
119
+ }
120
+ /** Every catalog the configured sources supply for `namespace`, in source order. */
121
+ declare function catalogsForNamespace(namespace: string, sources: readonly CatalogSource[], settings: {
122
+ readonly cwd: string;
123
+ readonly defaultNamespace: string;
124
+ readonly keySeparator: string | false;
125
+ }): NamespaceCatalogs;
126
+ interface KeyLookup {
127
+ /** Plural forms may answer for the key (the call passes, or may pass, `count`). */
128
+ readonly plural: boolean;
129
+ /** Context variants may answer for the key (the call passes, or may pass, `context`). */
130
+ readonly context: boolean;
131
+ /** The call asks for an object (`returnObjects`), so a branch answers too. */
132
+ readonly returnObjects: boolean;
133
+ readonly pluralSeparator: string;
134
+ readonly contextSeparator: string;
135
+ }
136
+ /** Does `key` resolve in `catalog` the way i18next would look it up? */
137
+ declare function catalogHasKey(catalog: Catalog, key: string, lookup: KeyLookup): boolean;
138
+ /** Does any key in `catalog` start with `prefix`? (the `dynamicKeys: 'check-prefix'` probe) */
139
+ declare function catalogHasPrefix(catalog: Catalog, prefix: string): boolean;
140
+
141
+ /**
142
+ * Static discovery of translation-key usages, i18next / react-i18next style.
143
+ *
144
+ * The visitor answers one question per call site: "which key, in which
145
+ * namespace(s)?" It never guesses. Every shape it cannot pin down statically
146
+ * (a variable key, a namespace held in a variable it cannot resolve, an opaque
147
+ * options bag) comes back as `dynamic` / `unresolved` so the caller can stay
148
+ * silent on it, the way sibling rules stay silent on spreads.
149
+ *
150
+ * Namespace sources it understands, all by syntax, all per file:
151
+ * `const { t } = useTranslation('ns', { keyPrefix })` (also `[t]`, `r.t`, aliases)
152
+ * `const t = i18n.getFixedT(lng, 'ns', keyPrefix)`
153
+ * `i18n.t(...)` / `i18next.t(...)` (default namespace)
154
+ * `function f(t: TFunction<'ns'>)` (typed parameter)
155
+ * `import { t } from 'i18next'` or a global `t` (default namespace)
156
+ * per call: `t('ns:key')`, `t('key', { ns: 'ns' })`
157
+ * JSX: `<Trans i18nKey="key" ns="ns" t={t} />`
158
+ * A namespace argument may be a literal, an array of literals, a same-file
159
+ * `const`, a name mapped in `namespaceIdentifiers`, or (under typed linting)
160
+ * any identifier whose type is a single string literal.
161
+ */
162
+ interface TranslationSettings {
163
+ readonly hooks: ReadonlySet<string>;
164
+ readonly instances: ReadonlySet<string>;
165
+ readonly functions: ReadonlySet<string>;
166
+ readonly typeNames: ReadonlySet<string>;
167
+ readonly transComponents: ReadonlySet<string>;
168
+ readonly namespaceIdentifiers: Readonly<Record<string, string>>;
169
+ readonly defaultNamespace: string;
170
+ readonly nsSeparator: string | false;
171
+ readonly keySeparator: string | false;
172
+ }
173
+ type TranslationUsage = {
174
+ /** Every key is static: the call is checkable. */
175
+ readonly kind: 'key';
176
+ readonly node: TSESTree.Node;
177
+ /** Namespaces i18next would search, in order. */
178
+ readonly namespaces: readonly string[];
179
+ /** Candidate keys (more than one for `t(['a', 'b'])`); any one resolving is enough. */
180
+ readonly keys: readonly string[];
181
+ readonly plural: boolean;
182
+ readonly context: boolean;
183
+ readonly returnObjects: boolean;
184
+ } | {
185
+ /** A template key with a static head: only its prefix is known. */
186
+ readonly kind: 'prefix';
187
+ readonly node: TSESTree.Node;
188
+ readonly namespaces: readonly string[];
189
+ readonly prefix: string;
190
+ } | {
191
+ /** The key itself is not static (`t(someVariable)`, `` t(`${x}`) ``). */
192
+ readonly kind: 'dynamic';
193
+ readonly node: TSESTree.Node;
194
+ } | {
195
+ /** A translation call whose namespace or options cannot be resolved statically. */
196
+ readonly kind: 'unresolved';
197
+ readonly node: TSESTree.Node;
198
+ };
199
+ type Context = Readonly<TSESLint.RuleContext<string, readonly unknown[]>>;
200
+ declare function createTranslationVisitor(context: Context, settings: TranslationSettings, onUsage: (usage: TranslationUsage) => void): TSESLint.RuleListener;
201
+
202
+ interface TranslationKeyExistsOptions {
203
+ /** Where each namespace's catalog lives. Empty = rule is inert. */
204
+ readonly catalogs?: readonly CatalogSource[];
205
+ /** The namespace an unqualified `useTranslation()` / `i18n.t` resolves to (i18next `defaultNS`). */
206
+ readonly defaultNamespace?: string;
207
+ /** Namespaces searched after the bound ones (i18next `fallbackNS`). */
208
+ readonly fallbackNamespaces?: readonly string[];
209
+ /** Hooks returning a namespace-bound `t` (`useTranslation`). */
210
+ readonly hooks?: readonly string[];
211
+ /** i18next instance identifiers: `<instance>.t(...)`, `<instance>.getFixedT(...)`. */
212
+ readonly instances?: readonly string[];
213
+ /** Free translation functions bound to the default namespace when imported or global (`t`). */
214
+ readonly functions?: readonly string[];
215
+ /** Type names whose first type argument names a parameter's namespace (`TFunction<'ns'>`). */
216
+ readonly typeNames?: readonly string[];
217
+ /** JSX components taking an `i18nKey` prop (`Trans`). */
218
+ readonly transComponents?: readonly string[];
219
+ /** Identifiers holding a namespace name that live in another module (`{ HELP_NS: 'help' }`). */
220
+ readonly namespaceIdentifiers?: Readonly<Record<string, string>>;
221
+ /** i18next `nsSeparator`; `false` disables `ns:key` parsing. */
222
+ readonly nsSeparator?: string | false;
223
+ /** i18next `keySeparator`; `false` means flat catalogs. */
224
+ readonly keySeparator?: string | false;
225
+ /** i18next `pluralSeparator`. */
226
+ readonly pluralSeparator?: string;
227
+ /** i18next `contextSeparator`. */
228
+ readonly contextSeparator?: string;
229
+ /** `ignore` stays silent on template keys; `check-prefix` requires their static head to exist. */
230
+ readonly dynamicKeys?: 'ignore' | 'check-prefix';
231
+ }
232
+ /** Defaults mirror i18next / react-i18next's own. */
233
+ declare const TRANSLATION_DEFAULTS: {
234
+ readonly defaultNamespace: "translation";
235
+ readonly hooks: readonly ["useTranslation"];
236
+ readonly instances: readonly ["i18n", "i18next"];
237
+ readonly functions: readonly ["t"];
238
+ readonly typeNames: readonly ["TFunction"];
239
+ readonly transComponents: readonly ["Trans"];
240
+ readonly nsSeparator: ":";
241
+ readonly keySeparator: ".";
242
+ readonly pluralSeparator: "_";
243
+ readonly contextSeparator: "_";
244
+ };
245
+ /** Normalise user options into the visitor's settings. */
246
+ declare function translationSettingsOf(options: TranslationKeyExistsOptions): TranslationSettings;
247
+
130
248
  /** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
131
249
  declare const rules: {
132
250
  'zod-schema-naming': _typescript_eslint_utils_ts_eslint.RuleModule<"schemaNaming" | "missingType", [ZodSchemaNamingOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
@@ -221,4 +339,4 @@ declare const plugin: {
221
339
 
222
340
  declare const configs: Record<string, unknown>;
223
341
 
224
- export { configs, plugin as default, rules };
342
+ export { type Catalog, type CatalogSource, type KeyLookup, type NamespaceCatalogs, TRANSLATION_DEFAULTS, type TranslationKeyExistsOptions, type TranslationSettings, type TranslationUsage, catalogHasKey, catalogHasPrefix, catalogsForNamespace, configs, createTranslationVisitor, plugin as default, rules, translationSettingsOf };
package/dist/index.js CHANGED
@@ -595,6 +595,7 @@ var DEFAULT_FIELD_PATTERNS = [
595
595
  "balance"
596
596
  ];
597
597
  var DEFAULT_ALLOWED_FILES = [];
598
+ var DEFAULT_MINOR_UNIT_PATTERNS = [];
598
599
  var optionSchema3 = {
599
600
  type: "object",
600
601
  additionalProperties: false,
@@ -610,6 +611,11 @@ var optionSchema3 = {
610
611
  type: "array",
611
612
  items: { type: "string" },
612
613
  uniqueItems: true
614
+ },
615
+ minorUnitPatterns: {
616
+ type: "array",
617
+ items: { type: "string", minLength: 1 },
618
+ uniqueItems: true
613
619
  }
614
620
  }
615
621
  };
@@ -648,7 +654,8 @@ var moneyMustBeDecimalRule = createRule({
648
654
  {
649
655
  decimalType: DEFAULT_DECIMAL_TYPE,
650
656
  fieldPatterns: [...DEFAULT_FIELD_PATTERNS],
651
- allowedFiles: []
657
+ allowedFiles: [],
658
+ minorUnitPatterns: []
652
659
  }
653
660
  ],
654
661
  create(context, [options]) {
@@ -659,6 +666,11 @@ var moneyMustBeDecimalRule = createRule({
659
666
  }
660
667
  const fieldPatterns = options.fieldPatterns ?? DEFAULT_FIELD_PATTERNS;
661
668
  const moneyPattern = new RegExp(`(${fieldPatterns.join("|")})`, "i");
669
+ const minorUnitPatterns = options.minorUnitPatterns ?? DEFAULT_MINOR_UNIT_PATTERNS;
670
+ const minorUnitPattern = minorUnitPatterns.length > 0 ? new RegExp(`(${minorUnitPatterns.join("|")})`, "i") : null;
671
+ function isDecimalMoneyName(name) {
672
+ return moneyPattern.test(name) && !(minorUnitPattern?.test(name) ?? false);
673
+ }
662
674
  function report(node) {
663
675
  context.report({ node, messageId: "moneyMustBeDecimal", data: { decimalType } });
664
676
  }
@@ -669,7 +681,7 @@ var moneyMustBeDecimalRule = createRule({
669
681
  return;
670
682
  }
671
683
  const name = staticName(node.key);
672
- if (name !== void 0 && moneyPattern.test(name) && isNumberAnnotation(node.typeAnnotation)) {
684
+ if (name !== void 0 && isDecimalMoneyName(name) && isNumberAnnotation(node.typeAnnotation)) {
673
685
  report(node);
674
686
  }
675
687
  },
@@ -679,7 +691,7 @@ var moneyMustBeDecimalRule = createRule({
679
691
  return;
680
692
  }
681
693
  const name = node.id.name;
682
- if (moneyPattern.test(name) && isNumberAnnotation(node.id.typeAnnotation)) {
694
+ if (isDecimalMoneyName(name) && isNumberAnnotation(node.id.typeAnnotation)) {
683
695
  report(node);
684
696
  }
685
697
  }
@@ -2263,7 +2275,13 @@ plugin.configs.recommended = {
2263
2275
  var configs = plugin.configs;
2264
2276
  var index_default = plugin;
2265
2277
  export {
2278
+ TRANSLATION_DEFAULTS,
2279
+ catalogHasKey,
2280
+ catalogHasPrefix,
2281
+ catalogsForNamespace,
2266
2282
  configs,
2283
+ createTranslationVisitor,
2267
2284
  index_default as default,
2268
- rules
2285
+ rules,
2286
+ translationSettingsOf
2269
2287
  };
@@ -15,7 +15,7 @@ declaration in lockstep.
15
15
  A static `process.env.FOO` or `import.meta.env.FOO` access whose key `FOO` is not declared in the
16
16
  configured schema file:
17
17
 
18
- ```ts
18
+ ```ts prose reason="the rule reads the schema file named in its options from disk"
19
19
  // schema (.env.example) declares DATABASE_URL, PORT, NODE_ENV
20
20
 
21
21
  // ✗
@@ -15,27 +15,37 @@ status the server actually sent. Check the response first, and the failure is na
15
15
 
16
16
  A `.json()` read on a response bound from a configured fetch callee when no check governs it.
17
17
 
18
- ```ts
19
- // ✗ no check at all
18
+ ```ts bad reports=4
19
+ // no check at all
20
20
  export async function loadUser(id: string) {
21
21
  const res = await fetch(`/api/users/${id}`);
22
22
  return res.json();
23
23
  }
24
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');
25
+ // the check comes after the body is already parsed
26
+ export async function loadOrder(id: string) {
27
+ const res = await fetch(`/api/orders/${id}`);
28
+ const data = await res.json();
29
+ if (!res.ok) throw new Error('failed');
30
+ return data;
31
+ }
28
32
 
29
- // ✗ a single-code guard lets every other error through
30
- if (res.status === 404) return null;
31
- return res.json();
33
+ // a single-code guard lets every other error through
34
+ export async function findUser(id: string) {
35
+ const res = await fetch(`/api/users/${id}`);
36
+ if (res.status === 404) return null;
37
+ return res.json();
38
+ }
32
39
 
33
- // ✗ `||` runs the parse exactly on the failure path
34
- return res.ok || res.json();
40
+ // `||` runs the parse exactly on the failure path
41
+ export async function loadFlags() {
42
+ const res = await fetch('/api/flags');
43
+ return res.ok || res.json();
44
+ }
35
45
  ```
36
46
 
37
- ```ts
38
- // ✓ guard clause
47
+ ```ts good
48
+ // guard clause
39
49
  export async function loadUser(id: string) {
40
50
  const res = await fetch(`/api/users/${id}`);
41
51
  if (!res.ok) {
@@ -44,16 +54,30 @@ export async function loadUser(id: string) {
44
54
  return res.json();
45
55
  }
46
56
 
47
- // ✓ the parse sits in the branch the check permits
48
- return res.ok ? res.json() : null;
57
+ // the parse sits in the branch the check permits
58
+ export async function findUser(id: string) {
59
+ const res = await fetch(`/api/users/${id}`);
60
+ return res.ok ? res.json() : null;
61
+ }
49
62
 
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; }
63
+ // a status split at the success/error boundary, or a switch on success codes
64
+ export async function loadOrder(id: string) {
65
+ const res = await fetch(`/api/orders/${id}`);
66
+ if (res.status >= 400) return null;
67
+ return res.json();
68
+ }
69
+
70
+ export async function createOrder() {
71
+ const res = await fetch('/api/orders', { method: 'POST' });
72
+ switch (res.status) { case 200: case 201: return res.json(); default: return null; }
73
+ }
53
74
 
54
- // ✓ an assertion helper (`assert`, `invariant`, `ensure*`, `expect*`)
55
- invariant(res.ok, 'user request failed');
56
- return res.json();
75
+ // an assertion helper (`assert`, `invariant`, `ensure*`, `expect*`)
76
+ export async function loadFlags() {
77
+ const res = await fetch('/api/flags');
78
+ invariant(res.ok, 'user request failed');
79
+ return res.json();
80
+ }
57
81
  ```
58
82
 
59
83
  Three response shapes are tracked: `const res = await fetch(...)` then `res.json()` in the same block,
@@ -20,12 +20,12 @@ Conservative on purpose. Untyped declarations and numeric-literal initializers (
20
20
  **not** flagged — those are usually counters/accumulators. Interface and type-literal members
21
21
  (`{ amount: number }`) are **out of scope** so non-money type members do not regress.
22
22
 
23
- ```ts
24
- // ✗
23
+ ```ts bad reports=2
25
24
  class Invoice { total: number; }
26
25
  const amount: number = 5;
26
+ ```
27
27
 
28
- // ✓
28
+ ```ts good
29
29
  class Invoice { total: Decimal; }
30
30
  const count: number = 3; // not a money name
31
31
  interface Payment { amount: number; } // type member, out of scope
@@ -38,6 +38,7 @@ interface Payment { amount: number; } // type member, out of scope
38
38
  | `decimalType` | `string` | `'Decimal'` | Name of the money type to require; appears in the report message. |
39
39
  | `fieldPatterns` | `string[]` | `['amount', 'price', 'cost', 'total', 'balance']` | Case-insensitive regex fragments identifying money-named fields (OR-combined). |
40
40
  | `allowedFiles` | `string[]` | `[]` | Path-suffix allowlist of files skipped entirely (e.g. `apps/api/src/legacy/totals.ts`). |
41
+ | `minorUnitPatterns` | `string[]` | `[]` | Case-insensitive regex fragments naming fields that hold integer minor units on purpose and are therefore not reported. Checked before `fieldPatterns`. |
41
42
 
42
43
  ```js
43
44
  'noctcore-contracts/money-must-be-decimal': ['error', {
@@ -46,7 +47,26 @@ interface Payment { amount: number; } // type member, out of scope
46
47
  }]
47
48
  ```
48
49
 
50
+ ## Talking to a payment API
51
+
52
+ Stripe and most payment APIs deal in integer minor units: `amount` is a number of cents, and that
53
+ is correct at that boundary. Without `minorUnitPatterns` this rule flags every one of those
54
+ fields, which is how a project ends up turning the rule off entirely and losing it everywhere
55
+ else.
56
+
57
+ Name the boundary fields instead, so the rule keeps policing the rest of the codebase:
58
+
59
+ ```json
60
+ { "minorUnitPatterns": ["amountInCents", "unitAmount", "^amount$"] }
61
+ ```
62
+
63
+ Prefer patterns that are specific to the boundary. A blanket `amount` would exempt every field
64
+ whose name contains it, including the internal totals this rule exists to protect.
65
+
49
66
  ## When not to use it
50
67
 
51
- If your project does not have a dedicated Decimal money type, or represents money as integer minor
52
- units (cents) typed `number` on purpose, this rule does not fit.
68
+ If your project has no dedicated Decimal money type, this rule does not fit.
69
+
70
+ If it represents money as integer minor units **everywhere**, by choice, the rule can still be
71
+ useful with `minorUnitPatterns` covering that convention, but at that point it is asserting a
72
+ naming convention rather than a type, and you may not want it.
@@ -15,19 +15,25 @@ Any `process.env` access, in every position — property read (`process.env.X`),
15
15
  (`process.env[X]`), destructure (`const { X } = process.env`), or the bare value passed / returned /
16
16
  assigned (`log(process.env)`, `return process.env`). Computed `process['env']` cannot bypass it.
17
17
 
18
- ```ts
19
- // ✗
18
+ ```ts bad reports=3
20
19
  const isProd = process.env.NODE_ENV === 'production';
21
20
  const { DATABASE_URL } = process.env;
22
21
  const env = process['env'];
22
+ ```
23
+
24
+ ```ts good
25
+ import { config } from '@/config';
23
26
 
24
- // ✓
25
27
  const isProd = config.isProduction;
26
28
  ```
27
29
 
28
30
  Files matched by the `allowedFiles` glob allowlist are skipped entirely, so bootstrap entrypoints,
29
31
  config files, and tests may still read `process.env` directly.
30
32
 
33
+ ```ts good filename=vite.config.ts relocation
34
+ const isProd = process.env.NODE_ENV === 'production';
35
+ ```
36
+
31
37
  ## Options
32
38
 
33
39
  | Option | Type | Default | Meaning |
@@ -9,7 +9,7 @@ discarding `error.cause`, the stack, and any custom fields. The value that reach
9
9
  sentence with no chain to the underlying failure. The guarded extractor idiom preserves the object for
10
10
  structured loggers and stays legal:
11
11
 
12
- ```ts
12
+ ```ts good
13
13
  error instanceof Error ? error.message : String(error)
14
14
  ```
15
15
 
@@ -18,14 +18,15 @@ error instanceof Error ? error.message : String(error)
18
18
  Only the three unambiguous cause-chain-dropping forms, and only when the operand is a known error
19
19
  identifier (default `error`, `err`, `e`, `cause`):
20
20
 
21
- ```ts
22
- // ✗
21
+ ```ts bad reports=4
23
22
  logger.error(`request failed: ${error}`);
24
- const msg = err.toString();
25
- const msg = error + "";
26
- const msg = "" + e;
23
+ const a = err.toString();
24
+ const b = error + "";
25
+ const c = "" + e;
26
+ ```
27
27
 
28
- // ✓ the guarded idiom (bare String(error) is intentionally NOT policed)
28
+ ```ts good
29
+ // the guarded idiom (bare String(error) is intentionally NOT policed)
29
30
  const msg = error instanceof Error ? error.message : String(error);
30
31
  const m = `${error.message}`;
31
32
  ```
@@ -9,7 +9,7 @@ forward the caught error as `cause`, the underlying failure — its message, sta
9
9
  cause — is gone. The stack you see points only at the re-throw site. `Error`'s standard `cause`
10
10
  option (and every `*Error` subclass that forwards it) preserves the chain:
11
11
 
12
- ```ts
12
+ ```ts good
13
13
  try {
14
14
  await db.query(sql);
15
15
  } catch (err) {
@@ -34,14 +34,15 @@ Deliberately conservative:
34
34
  A `throw` nested in a closure declared inside the catch is still flagged: the binding is genuinely in
35
35
  scope there. Nested `try/catch` uses the nearest binding.
36
36
 
37
- ```ts
38
- // ✗ drops the cause → autofixes to `new Error('failed', { cause: err })`
37
+ ```ts bad reports=2
38
+ // drops the cause: autofixes to `new Error('failed', { cause: err })`
39
39
  try { work(); } catch (err) { throw new Error('failed'); }
40
40
 
41
- // ✗ merges into an existing options object
41
+ // merges into an existing options object
42
42
  try { work(); } catch (err) { throw new HttpError('failed', { status: 500 }); }
43
+ ```
43
44
 
44
- // ✓
45
+ ```ts good
45
46
  try { work(); } catch (err) { throw new Error('failed', { cause: err }); }
46
47
  try { work(); } catch (err) { throw err; }
47
48
  ```
@@ -14,14 +14,13 @@ compile error instead of a runtime miss.
14
14
 
15
15
  A **raw string literal** in the configured key position of a configured sink call:
16
16
 
17
- ```ts
18
- // with sinks: [{ callee: 'localStorage.getItem', argIndex: 0 }, { callee: 'emitter.on', argIndex: 0 }]
19
-
20
- // ✗
17
+ ```ts bad reports=2 options={"sinks":[{"callee":"localStorage.getItem","argIndex":0},{"callee":"emitter.on","argIndex":0}]}
21
18
  localStorage.getItem('user-profile');
22
19
  emitter.on('task-done', handler);
20
+ ```
23
21
 
24
- // ✓ imported constant
22
+ ```ts good options={"sinks":[{"callee":"localStorage.getItem","argIndex":0},{"callee":"emitter.on","argIndex":0}]}
23
+ // imported constant
25
24
  import { USER_PROFILE_KEY, TASK_DONE } from '@/keys';
26
25
  localStorage.getItem(USER_PROFILE_KEY);
27
26
  emitter.on(TASK_DONE, handler);
@@ -10,7 +10,7 @@ promise the runtime never checks: a field the server dropped is now `undefined`
10
10
  `string`, and the corruption surfaces far from the boundary. Parsing with a runtime schema
11
11
  (zod/valibot) validates the shape at the edge and fails loudly there:
12
12
 
13
- ```ts
13
+ ```ts good
14
14
  const user = UserSchema.parse(await res.json()); // validated
15
15
  ```
16
16
 
@@ -19,15 +19,15 @@ const user = UserSchema.parse(await res.json()); // validated
19
19
  This is a **conservative syntactic slice** of a concept that is fully general only with type
20
20
  information. It flags a cast applied **directly** to a call site that is unmistakably a boundary read:
21
21
 
22
- ```ts
23
- // ✗
22
+ ```ts bad reports=3
24
23
  const user = JSON.parse(raw) as User;
25
24
  const users = JSON.parse(raw) as User[];
26
- const user = (await res.json()) as User;
25
+ const fetched = (await res.json()) as User;
26
+ ```
27
27
 
28
- // ✓
28
+ ```ts good
29
29
  const user = UserSchema.parse(JSON.parse(raw));
30
- const user = UserSchema.parse(await res.json());
30
+ const fetched = UserSchema.parse(await res.json());
31
31
  const data = JSON.parse(raw) as unknown; // safe widening, not a shape claim
32
32
  ```
33
33
 
@@ -19,15 +19,17 @@ Conservative on the ambiguous forms. A bare identifier (`throw err` — the re-t
19
19
  (`throw ctx.error`), and a call (`throw makeError()`) are all left alone: a syntactic rule cannot know
20
20
  whether they resolve to an Error, and re-throwing a caught error is the most common `throw` there is.
21
21
 
22
- ```ts
23
- // ✗ built-in not in the taxonomy
22
+ ```ts bad reports=3
23
+ // built-in not in the taxonomy
24
24
  throw new TypeError('bad');
25
25
 
26
- // ✗ bare values
26
+ // bare values
27
27
  throw 'boom';
28
28
  throw { code: 500 };
29
+ ```
29
30
 
30
- // ✓ (default allow is ['Error'])
31
+ ```ts good
32
+ // default allow is ['Error']
31
33
  throw new Error('boom');
32
34
  try { work(); } catch (err) { throw err; }
33
35
  ```
@@ -26,8 +26,8 @@ Per file, purely syntactic, no type information. The rule collects every propert
26
26
  Every **string** occurrence of a key that is **enum** elsewhere in the file is reported. No autofix:
27
27
  the right fix may be a data migration (the stored column was free text), not a schema edit.
28
28
 
29
- ```ts
30
- // ✗ the output widens what both inputs narrow
29
+ ```ts bad reports=1
30
+ // the output widens what both inputs narrow
31
31
  export const statusSchema = z.enum(['OPEN', 'CLOSED']);
32
32
 
33
33
  export const ticketCreateInput = z.object({ status: statusSchema.default('OPEN') });
@@ -38,8 +38,12 @@ export const ticketOutput = z.object({
38
38
  });
39
39
  ```
40
40
 
41
- ```ts
42
- // ✓ the output reuses the enum
41
+ ```ts good
42
+ // the output reuses the enum
43
+ export const statusSchema = z.enum(['OPEN', 'CLOSED']);
44
+
45
+ export const ticketCreateInput = z.object({ status: statusSchema.default('OPEN') });
46
+ export const ticketUpdateInput = z.object({ status: statusSchema.optional() });
43
47
  export const ticketOutput = z.object({
44
48
  id: z.string(),
45
49
  status: statusSchema.nullable(),
@@ -14,7 +14,7 @@ the ones that cannot resolve.
14
14
 
15
15
  A static key that no configured catalog of the namespace in scope contains:
16
16
 
17
- ```tsx
17
+ ```tsx prose reason="the rule reads the translation catalogs named in its options from disk"
18
18
  // catalogs: common = { actions: { save, cancel } }, portal = { tasks: { title } }
19
19
 
20
20
  const { t } = useTranslation(); // default namespace: common
@@ -16,15 +16,17 @@ whose zod object declares a `type: z.literal('…')` property, the literal must
16
16
  `kebab(constName minus the role suffix)`. A mismatch is reported and **autofixed** to the expected
17
17
  value.
18
18
 
19
- ```ts
20
- // ✗ camelCase discriminant → autofixes to 'task-completed'
19
+ ```ts bad reports=2
20
+ // camelCase discriminant: autofixes to 'task-completed'
21
21
  export const TaskCompletedEvent = z.object({ type: z.literal('taskCompleted') });
22
22
 
23
- // ✗ wrong value → autofixes to 'run-task'
23
+ // wrong value: autofixes to 'run-task'
24
24
  export const RunTaskCommand = z.object({ type: z.literal('run') });
25
+ ```
25
26
 
26
- // ✓
27
+ ```ts good
27
28
  export const TaskCompletedEvent = z.object({ type: z.literal('task-completed') });
29
+ export const RunTaskCommand = z.object({ type: z.literal('run-task') });
28
30
  ```
29
31
 
30
32
  Consts without a role suffix, and role-suffixed consts without a `type` literal, are ignored.
@@ -39,8 +41,8 @@ Consts without a role suffix, and role-suffixed consts without a `type` literal,
39
41
  'noctcore-contracts/wire-message-naming': ['error', { roleSuffixes: ['Message'] }]
40
42
  ```
41
43
 
42
- ```ts
43
- // with roleSuffixes: ['Message'] → autofixes to 'task-done'
44
+ ```ts bad options={"roleSuffixes":["Message"]}
45
+ // with roleSuffixes: ['Message'], autofixes to 'task-done'
44
46
  export const TaskDoneMessage = z.object({ type: z.literal('done') });
45
47
  ```
46
48
 
@@ -17,14 +17,15 @@ For every `export const` whose initializer is rooted at the `z` identifier (`z.o
17
17
  - a correctly-named `FooSchema` must have a sibling `export type Foo` (a `type` alias or
18
18
  `interface`) — otherwise `missingType`.
19
19
 
20
- ```ts
21
- // ✗ not suffixed `Schema`
20
+ ```ts bad reports=2
21
+ // not suffixed `Schema`
22
22
  export const Task = z.object({});
23
23
 
24
- // ✗ no sibling inferred type
24
+ // no sibling inferred type
25
25
  export const TaskSchema = z.object({});
26
+ ```
26
27
 
27
- // ✓
28
+ ```ts good
28
29
  export const TaskSchema = z.object({ id: z.string() });
29
30
  export type Task = z.infer<typeof TaskSchema>;
30
31
  ```
@@ -43,7 +44,7 @@ suffixes to carve them out:
43
44
  'noctcore-contracts/zod-schema-naming': ['error', { roleSuffixes: ['Event', 'Command', 'Query'] }]
44
45
  ```
45
46
 
46
- ```ts
47
+ ```ts good options={"roleSuffixes":["Event","Command","Query"]} reconfigured
47
48
  // carved out only when 'Command' is listed in roleSuffixes
48
49
  export const RunTaskCommand = z.object({ type: z.literal('run-task') });
49
50
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noctcore/eslint-plugin-contracts",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",