@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 +29 -5
- package/dist/index.d.cts +175 -57
- package/dist/index.d.ts +175 -57
- package/dist/index.js +22 -4
- package/docs/rules/env-var-schema-parity.md +1 -1
- package/docs/rules/fetch-must-check-ok.md +44 -20
- package/docs/rules/money-must-be-decimal.md +25 -5
- package/docs/rules/no-direct-process-env.md +9 -3
- package/docs/rules/no-error-stringify.md +8 -7
- package/docs/rules/require-error-cause.md +6 -5
- package/docs/rules/require-registered-keys.md +4 -5
- package/docs/rules/require-schema-parse-at-boundary.md +6 -6
- package/docs/rules/restrict-throw-to-taxonomy.md +6 -4
- package/docs/rules/schema-enum-field-consistency.md +8 -4
- package/docs/rules/translation-key-exists.md +1 -1
- package/docs/rules/wire-message-naming.md +8 -6
- package/docs/rules/zod-schema-naming.md +6 -5
- package/package.json +1 -1
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 &&
|
|
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 (
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
34
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
48
|
-
|
|
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
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
52
|
-
|
|
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
|
|
25
|
-
const
|
|
26
|
-
const
|
|
23
|
+
const a = err.toString();
|
|
24
|
+
const b = error + "";
|
|
25
|
+
const c = "" + e;
|
|
26
|
+
```
|
|
27
27
|
|
|
28
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
22
|
+
```ts bad reports=3
|
|
23
|
+
// built-in not in the taxonomy
|
|
24
24
|
throw new TypeError('bad');
|
|
25
25
|
|
|
26
|
-
//
|
|
26
|
+
// bare values
|
|
27
27
|
throw 'boom';
|
|
28
28
|
throw { code: 500 };
|
|
29
|
+
```
|
|
29
30
|
|
|
30
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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']
|
|
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
|
-
//
|
|
20
|
+
```ts bad reports=2
|
|
21
|
+
// not suffixed `Schema`
|
|
22
22
|
export const Task = z.object({});
|
|
23
23
|
|
|
24
|
-
//
|
|
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.
|
|
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",
|