@noctcore/eslint-plugin-contracts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/index.cjs +544 -0
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +516 -0
- package/docs/rules/money-must-be-decimal.md +52 -0
- package/docs/rules/no-direct-process-env.md +51 -0
- package/docs/rules/no-error-stringify.md +47 -0
- package/docs/rules/wire-message-naming.md +51 -0
- package/docs/rules/zod-schema-naming.md +54 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @noctcore/eslint-plugin-contracts
|
|
2
|
+
|
|
3
|
+
Rules for shared **contract** conventions — zod schema naming, wire-message discriminants, error
|
|
4
|
+
stringification, direct `process.env` access, and money precision. Flat-config only, ESLint 9+.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add -D @noctcore/eslint-plugin-contracts # or npm i -D / pnpm add -D
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
// eslint.config.js
|
|
16
|
+
import contracts from '@noctcore/eslint-plugin-contracts';
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
contracts.configs.recommended,
|
|
20
|
+
];
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or wire rules individually:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import contracts from '@noctcore/eslint-plugin-contracts';
|
|
27
|
+
|
|
28
|
+
export default [
|
|
29
|
+
{
|
|
30
|
+
plugins: { 'noctcore-contracts': contracts },
|
|
31
|
+
rules: {
|
|
32
|
+
'noctcore-contracts/zod-schema-naming': ['error', { roleSuffixes: ['Event', 'Command', 'Query'] }],
|
|
33
|
+
'noctcore-contracts/no-direct-process-env': ['error', { configModule: '@acme/config' }],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Rules
|
|
40
|
+
|
|
41
|
+
| Rule | Description | 🔧 |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| [`zod-schema-naming`](./docs/rules/zod-schema-naming.md) | Exported zod schema must be a PascalCase `*Schema` const with a sibling `z.infer` type. | |
|
|
44
|
+
| [`wire-message-naming`](./docs/rules/wire-message-naming.md) | A role-suffixed schema's `type: z.literal(...)` must be kebab-case of its name minus the suffix. | 🔧 |
|
|
45
|
+
| [`no-error-stringify`](./docs/rules/no-error-stringify.md) | Ban `${error}` / `error.toString()` / `error + ""` — they drop the cause chain. | |
|
|
46
|
+
| [`no-direct-process-env`](./docs/rules/no-direct-process-env.md) | Ban direct `process.env`; require a typed config accessor. | |
|
|
47
|
+
| [`money-must-be-decimal`](./docs/rules/money-must-be-decimal.md) | Money-named fields typed `: number` are banned; require a Decimal money type. | |
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
configs: () => configs,
|
|
24
|
+
default: () => index_default,
|
|
25
|
+
rules: () => rules
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/configs/recommended.ts
|
|
30
|
+
var recommended = {
|
|
31
|
+
"noctcore-contracts/zod-schema-naming": "error",
|
|
32
|
+
"noctcore-contracts/wire-message-naming": "error",
|
|
33
|
+
"noctcore-contracts/no-error-stringify": "error",
|
|
34
|
+
"noctcore-contracts/no-direct-process-env": "error",
|
|
35
|
+
"noctcore-contracts/money-must-be-decimal": "error"
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/rules/money-must-be-decimal.ts
|
|
39
|
+
var import_utils = require("@typescript-eslint/utils");
|
|
40
|
+
|
|
41
|
+
// src/createRule.ts
|
|
42
|
+
var import_eslint_utils = require("@noctcore/eslint-utils");
|
|
43
|
+
var createRule = (0, import_eslint_utils.makeCreateRule)("contracts");
|
|
44
|
+
|
|
45
|
+
// src/rules/money-must-be-decimal.ts
|
|
46
|
+
var RULE_NAME = "money-must-be-decimal";
|
|
47
|
+
var DEFAULT_DECIMAL_TYPE = "Decimal";
|
|
48
|
+
var DEFAULT_FIELD_PATTERNS = [
|
|
49
|
+
"amount",
|
|
50
|
+
"price",
|
|
51
|
+
"cost",
|
|
52
|
+
"total",
|
|
53
|
+
"balance"
|
|
54
|
+
];
|
|
55
|
+
var DEFAULT_ALLOWED_FILES = [];
|
|
56
|
+
var optionSchema = {
|
|
57
|
+
type: "object",
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: {
|
|
60
|
+
decimalType: { type: "string", minLength: 1 },
|
|
61
|
+
fieldPatterns: {
|
|
62
|
+
type: "array",
|
|
63
|
+
items: { type: "string" },
|
|
64
|
+
uniqueItems: true,
|
|
65
|
+
minItems: 1
|
|
66
|
+
},
|
|
67
|
+
allowedFiles: {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: { type: "string" },
|
|
70
|
+
uniqueItems: true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function toForwardSlash(filename) {
|
|
75
|
+
return filename.split("\\").join("/");
|
|
76
|
+
}
|
|
77
|
+
function isAllowedFile(filename, patterns) {
|
|
78
|
+
if (patterns.length === 0) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const normalized = toForwardSlash(filename);
|
|
82
|
+
return patterns.some((pattern) => normalized.endsWith(toForwardSlash(pattern)));
|
|
83
|
+
}
|
|
84
|
+
function staticName(node) {
|
|
85
|
+
if (node.type === import_utils.AST_NODE_TYPES.Identifier) {
|
|
86
|
+
return node.name;
|
|
87
|
+
}
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
function isNumberAnnotation(annotation) {
|
|
91
|
+
return annotation?.typeAnnotation.type === import_utils.AST_NODE_TYPES.TSNumberKeyword;
|
|
92
|
+
}
|
|
93
|
+
var moneyMustBeDecimalRule = createRule({
|
|
94
|
+
name: RULE_NAME,
|
|
95
|
+
meta: {
|
|
96
|
+
type: "problem",
|
|
97
|
+
docs: {
|
|
98
|
+
description: "Disallow monetary values typed as the JS primitive `number`. Money-named fields explicitly typed `: number` lose precision to float rounding; use a Decimal money type instead."
|
|
99
|
+
},
|
|
100
|
+
schema: [optionSchema],
|
|
101
|
+
messages: {
|
|
102
|
+
moneyMustBeDecimal: "Monetary values must use {{decimalType}}, never the JS `number` primitive, to avoid float rounding errors. Rename or retype this field to a {{decimalType}} money type."
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
defaultOptions: [
|
|
106
|
+
{
|
|
107
|
+
decimalType: DEFAULT_DECIMAL_TYPE,
|
|
108
|
+
fieldPatterns: [...DEFAULT_FIELD_PATTERNS],
|
|
109
|
+
allowedFiles: []
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
create(context, [options]) {
|
|
113
|
+
const decimalType = options.decimalType ?? DEFAULT_DECIMAL_TYPE;
|
|
114
|
+
const allowedFiles = options.allowedFiles ?? DEFAULT_ALLOWED_FILES;
|
|
115
|
+
if (isAllowedFile(context.filename, allowedFiles)) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
const fieldPatterns = options.fieldPatterns ?? DEFAULT_FIELD_PATTERNS;
|
|
119
|
+
const moneyPattern = new RegExp(`(${fieldPatterns.join("|")})`, "i");
|
|
120
|
+
function report(node) {
|
|
121
|
+
context.report({ node, messageId: "moneyMustBeDecimal", data: { decimalType } });
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
// `class Invoice { total: number }`: class field explicitly typed number.
|
|
125
|
+
PropertyDefinition(node) {
|
|
126
|
+
if (node.computed) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const name = staticName(node.key);
|
|
130
|
+
if (name !== void 0 && moneyPattern.test(name) && isNumberAnnotation(node.typeAnnotation)) {
|
|
131
|
+
report(node);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
// `const total: number = ...`: annotated variable declarator.
|
|
135
|
+
VariableDeclarator(node) {
|
|
136
|
+
if (node.id.type !== import_utils.AST_NODE_TYPES.Identifier) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const name = node.id.name;
|
|
140
|
+
if (moneyPattern.test(name) && isNumberAnnotation(node.id.typeAnnotation)) {
|
|
141
|
+
report(node);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// src/rules/no-direct-process-env.ts
|
|
149
|
+
var import_utils2 = require("@typescript-eslint/utils");
|
|
150
|
+
var RULE_NAME2 = "no-direct-process-env";
|
|
151
|
+
var DEFAULT_CONFIG_MODULE = "@/config";
|
|
152
|
+
var DEFAULT_ALLOWED_FILES2 = [
|
|
153
|
+
"**/*.config.{ts,js,mjs,cjs}",
|
|
154
|
+
"**/*.{spec,test}.{ts,tsx}",
|
|
155
|
+
"**/scripts/**"
|
|
156
|
+
];
|
|
157
|
+
var optionSchema2 = {
|
|
158
|
+
type: "object",
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
properties: {
|
|
161
|
+
configModule: { type: "string", minLength: 1 },
|
|
162
|
+
allowedFiles: {
|
|
163
|
+
type: "array",
|
|
164
|
+
items: { type: "string" },
|
|
165
|
+
uniqueItems: true
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
function globToRegExp(glob) {
|
|
170
|
+
let re = "";
|
|
171
|
+
let braceDepth = 0;
|
|
172
|
+
for (let i = 0; i < glob.length; i++) {
|
|
173
|
+
const c = glob[i];
|
|
174
|
+
if (c === "*") {
|
|
175
|
+
if (glob[i + 1] === "*") {
|
|
176
|
+
i++;
|
|
177
|
+
if (glob[i + 1] === "/") {
|
|
178
|
+
i++;
|
|
179
|
+
re += "(?:.*/)?";
|
|
180
|
+
} else {
|
|
181
|
+
re += ".*";
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
re += "[^/]*";
|
|
185
|
+
}
|
|
186
|
+
} else if (c === "?") {
|
|
187
|
+
re += "[^/]";
|
|
188
|
+
} else if (c === "{") {
|
|
189
|
+
re += "(?:";
|
|
190
|
+
braceDepth++;
|
|
191
|
+
} else if (c === "}") {
|
|
192
|
+
re += ")";
|
|
193
|
+
if (braceDepth > 0) braceDepth--;
|
|
194
|
+
} else if (c === ",") {
|
|
195
|
+
re += braceDepth > 0 ? "|" : "\\,";
|
|
196
|
+
} else if (".+^$()|[]\\/".includes(c)) {
|
|
197
|
+
re += `\\${c}`;
|
|
198
|
+
} else {
|
|
199
|
+
re += c;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return new RegExp(`^${re}$`);
|
|
203
|
+
}
|
|
204
|
+
function isAllowedFile2(filename, patterns) {
|
|
205
|
+
if (patterns.length === 0) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
const normalized = filename.split("\\").join("/");
|
|
209
|
+
return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
210
|
+
}
|
|
211
|
+
function isProcessEnv(node) {
|
|
212
|
+
if (node.type !== import_utils2.AST_NODE_TYPES.MemberExpression || node.object.type !== import_utils2.AST_NODE_TYPES.Identifier || node.object.name !== "process") {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (node.computed) {
|
|
216
|
+
return node.property.type === import_utils2.AST_NODE_TYPES.Literal && node.property.value === "env";
|
|
217
|
+
}
|
|
218
|
+
return node.property.type === import_utils2.AST_NODE_TYPES.Identifier && node.property.name === "env";
|
|
219
|
+
}
|
|
220
|
+
var noDirectProcessEnvRule = createRule({
|
|
221
|
+
name: RULE_NAME2,
|
|
222
|
+
meta: {
|
|
223
|
+
type: "problem",
|
|
224
|
+
docs: {
|
|
225
|
+
description: "Disallow direct `process.env` access. Force every consumer through a typed, validated config accessor so a missing variable fails at boot, not at use."
|
|
226
|
+
},
|
|
227
|
+
schema: [optionSchema2],
|
|
228
|
+
messages: {
|
|
229
|
+
directProcessEnv: "Read environment variables through your typed config accessor (import from `{{configModule}}`). Direct `process.env` access bypasses boot-time validation."
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
defaultOptions: [
|
|
233
|
+
{
|
|
234
|
+
configModule: DEFAULT_CONFIG_MODULE,
|
|
235
|
+
allowedFiles: [...DEFAULT_ALLOWED_FILES2]
|
|
236
|
+
}
|
|
237
|
+
],
|
|
238
|
+
create(context, [options]) {
|
|
239
|
+
const configModule = options.configModule ?? DEFAULT_CONFIG_MODULE;
|
|
240
|
+
const allowedFiles = options.allowedFiles ?? DEFAULT_ALLOWED_FILES2;
|
|
241
|
+
if (isAllowedFile2(context.filename, allowedFiles)) {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
/*
|
|
246
|
+
* `process.env` is itself a MemberExpression, so a single visitor on the
|
|
247
|
+
* node catches every usage position: property access (`process.env.X`),
|
|
248
|
+
* computed access (`process.env[X]`), destructuring (`const { X } =
|
|
249
|
+
* process.env`), and bare value usage where it is passed as an argument,
|
|
250
|
+
* returned, or assigned (`log(process.env)`, `return process.env`).
|
|
251
|
+
*/
|
|
252
|
+
MemberExpression(node) {
|
|
253
|
+
if (isProcessEnv(node)) {
|
|
254
|
+
context.report({ node, messageId: "directProcessEnv", data: { configModule } });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// src/rules/no-error-stringify.ts
|
|
262
|
+
var import_utils3 = require("@typescript-eslint/utils");
|
|
263
|
+
var RULE_NAME3 = "no-error-stringify";
|
|
264
|
+
var DEFAULT_ERROR_NAMES = ["error", "err", "e", "cause"];
|
|
265
|
+
var optionSchema3 = {
|
|
266
|
+
type: "object",
|
|
267
|
+
additionalProperties: false,
|
|
268
|
+
properties: {
|
|
269
|
+
errorIdentifierNames: {
|
|
270
|
+
type: "array",
|
|
271
|
+
items: { type: "string" },
|
|
272
|
+
uniqueItems: true,
|
|
273
|
+
minItems: 1
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
function isEmptyStringLiteral(node) {
|
|
278
|
+
return node.type === import_utils3.AST_NODE_TYPES.Literal && node.value === "";
|
|
279
|
+
}
|
|
280
|
+
function isErrorIdentifier(node, names) {
|
|
281
|
+
return node.type === import_utils3.AST_NODE_TYPES.Identifier && names.has(node.name);
|
|
282
|
+
}
|
|
283
|
+
var noErrorStringifyRule = createRule({
|
|
284
|
+
name: RULE_NAME3,
|
|
285
|
+
meta: {
|
|
286
|
+
type: "problem",
|
|
287
|
+
docs: {
|
|
288
|
+
description: 'Disallow stringifying an error with bare `${error}` interpolation, `error.toString()`, or `error + ""`. These drop the cause chain. Use `error instanceof Error ? error.message : String(error)` instead.'
|
|
289
|
+
},
|
|
290
|
+
schema: [optionSchema3],
|
|
291
|
+
messages: {
|
|
292
|
+
noErrorStringify: "Stringifying an error this way drops its cause chain. Use `{{name}} instanceof Error ? {{name}}.message : String({{name}})` (or pass the Error object straight to the logger)."
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
defaultOptions: [{ errorIdentifierNames: [...DEFAULT_ERROR_NAMES] }],
|
|
296
|
+
create(context, [options]) {
|
|
297
|
+
const errorNames = new Set(options.errorIdentifierNames ?? DEFAULT_ERROR_NAMES);
|
|
298
|
+
function report(node, name) {
|
|
299
|
+
context.report({ node, messageId: "noErrorStringify", data: { name } });
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
// `error.toString()`
|
|
303
|
+
'CallExpression[callee.type="MemberExpression"]'(node) {
|
|
304
|
+
const callee = node.callee;
|
|
305
|
+
if (!callee.computed && callee.property.type === import_utils3.AST_NODE_TYPES.Identifier && callee.property.name === "toString" && node.arguments.length === 0 && isErrorIdentifier(callee.object, errorNames)) {
|
|
306
|
+
report(node, callee.object.name);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
// bare `${error}` inside a template literal
|
|
310
|
+
TemplateLiteral(node) {
|
|
311
|
+
for (const expr of node.expressions) {
|
|
312
|
+
if (isErrorIdentifier(expr, errorNames)) {
|
|
313
|
+
report(expr, expr.name);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
// `error + ""` or `"" + error`
|
|
318
|
+
BinaryExpression(node) {
|
|
319
|
+
if (node.operator !== "+") {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const sides = [node.left, node.right];
|
|
323
|
+
if (!sides.some(isEmptyStringLiteral)) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const side of sides) {
|
|
327
|
+
if (isErrorIdentifier(side, errorNames)) {
|
|
328
|
+
report(node, side.name);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// src/rules/wire-message-naming.ts
|
|
338
|
+
var RULE_NAME4 = "wire-message-naming";
|
|
339
|
+
var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
|
|
340
|
+
var optionSchema4 = {
|
|
341
|
+
type: "object",
|
|
342
|
+
additionalProperties: false,
|
|
343
|
+
properties: {
|
|
344
|
+
roleSuffixes: {
|
|
345
|
+
type: "array",
|
|
346
|
+
items: { type: "string" },
|
|
347
|
+
uniqueItems: true,
|
|
348
|
+
minItems: 1
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
function kebab(input) {
|
|
353
|
+
return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
354
|
+
}
|
|
355
|
+
function roleSuffix(name, suffixes) {
|
|
356
|
+
return suffixes.find((s) => name.endsWith(s) && name.length > s.length) ?? null;
|
|
357
|
+
}
|
|
358
|
+
function unwrapToObject(node) {
|
|
359
|
+
let cur = node;
|
|
360
|
+
while (cur && cur.type === "CallExpression") {
|
|
361
|
+
const objArg = cur.arguments.find((a) => a.type === "ObjectExpression");
|
|
362
|
+
if (objArg && objArg.type === "ObjectExpression") return objArg;
|
|
363
|
+
cur = cur.callee.type === "MemberExpression" ? cur.callee.object : null;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
function typeLiteralNode(obj) {
|
|
368
|
+
for (const p of obj.properties) {
|
|
369
|
+
if (p.type !== "Property") continue;
|
|
370
|
+
const isType = p.key.type === "Identifier" && p.key.name === "type" || p.key.type === "Literal" && p.key.value === "type";
|
|
371
|
+
if (!isType) continue;
|
|
372
|
+
const v = p.value;
|
|
373
|
+
if (v.type === "CallExpression" && v.callee.type === "MemberExpression" && v.callee.property.type === "Identifier" && v.callee.property.name === "literal") {
|
|
374
|
+
const arg = v.arguments[0];
|
|
375
|
+
if (arg && arg.type === "Literal" && typeof arg.value === "string") return arg;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
var wireMessageNamingRule = createRule({
|
|
381
|
+
name: RULE_NAME4,
|
|
382
|
+
meta: {
|
|
383
|
+
type: "problem",
|
|
384
|
+
docs: {
|
|
385
|
+
description: "A message-schema const ending in a role suffix (default Event/Command/Query) whose zod object declares `type: z.literal(...)` must set that literal to kebab-case(const name minus its role suffix)."
|
|
386
|
+
},
|
|
387
|
+
fixable: "code",
|
|
388
|
+
schema: [optionSchema4],
|
|
389
|
+
messages: {
|
|
390
|
+
typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
defaultOptions: [{ roleSuffixes: ["Event", "Command", "Query"] }],
|
|
394
|
+
create(context, [options]) {
|
|
395
|
+
const roleSuffixes = options.roleSuffixes ?? DEFAULT_ROLE_SUFFIXES;
|
|
396
|
+
return {
|
|
397
|
+
ExportNamedDeclaration(node) {
|
|
398
|
+
const decl = node.declaration;
|
|
399
|
+
if (!decl || decl.type !== "VariableDeclaration") return;
|
|
400
|
+
for (const d of decl.declarations) {
|
|
401
|
+
if (d.id.type !== "Identifier" || !d.init) continue;
|
|
402
|
+
const suffix = roleSuffix(d.id.name, roleSuffixes);
|
|
403
|
+
if (!suffix) continue;
|
|
404
|
+
const obj = unwrapToObject(d.init);
|
|
405
|
+
if (!obj) continue;
|
|
406
|
+
const lit = typeLiteralNode(obj);
|
|
407
|
+
if (!lit) continue;
|
|
408
|
+
const expected = kebab(d.id.name.slice(0, -suffix.length));
|
|
409
|
+
if (lit.value !== expected) {
|
|
410
|
+
context.report({
|
|
411
|
+
node: lit,
|
|
412
|
+
messageId: "typeMismatch",
|
|
413
|
+
data: { name: d.id.name, actual: String(lit.value), expected },
|
|
414
|
+
fix: (fixer) => fixer.replaceText(lit, `'${expected}'`)
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// src/rules/zod-schema-naming.ts
|
|
424
|
+
var RULE_NAME5 = "zod-schema-naming";
|
|
425
|
+
var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
|
|
426
|
+
var SUFFIX = "Schema";
|
|
427
|
+
var DEFAULT_ROLE_SUFFIXES2 = [];
|
|
428
|
+
var optionSchema5 = {
|
|
429
|
+
type: "object",
|
|
430
|
+
additionalProperties: false,
|
|
431
|
+
properties: {
|
|
432
|
+
roleSuffixes: {
|
|
433
|
+
type: "array",
|
|
434
|
+
items: { type: "string" },
|
|
435
|
+
uniqueItems: true
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
function hasRoleSuffix(name, suffixes) {
|
|
440
|
+
return suffixes.some((s) => name.endsWith(s) && name.length > s.length);
|
|
441
|
+
}
|
|
442
|
+
function rootIdentifierName(node) {
|
|
443
|
+
let current = node;
|
|
444
|
+
while (current) {
|
|
445
|
+
switch (current.type) {
|
|
446
|
+
case "CallExpression":
|
|
447
|
+
current = current.callee;
|
|
448
|
+
break;
|
|
449
|
+
case "MemberExpression":
|
|
450
|
+
current = current.object;
|
|
451
|
+
break;
|
|
452
|
+
case "Identifier":
|
|
453
|
+
return current.name;
|
|
454
|
+
default:
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
var zodSchemaNamingRule = createRule({
|
|
461
|
+
name: RULE_NAME5,
|
|
462
|
+
meta: {
|
|
463
|
+
type: "problem",
|
|
464
|
+
docs: {
|
|
465
|
+
description: "Every exported zod schema is a PascalCase const suffixed `Schema`, paired with a same-named inferred type (`export type Foo = z.infer<typeof FooSchema>`)."
|
|
466
|
+
},
|
|
467
|
+
schema: [optionSchema5],
|
|
468
|
+
messages: {
|
|
469
|
+
schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
|
|
470
|
+
missingType: "Schema `{{name}}` has no sibling `export type {{base}} = z.infer<typeof {{name}}>`. Export the inferred type instead of hand-authoring a duplicate."
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
defaultOptions: [{ roleSuffixes: [] }],
|
|
474
|
+
create(context, [options]) {
|
|
475
|
+
const roleSuffixes = options.roleSuffixes ?? DEFAULT_ROLE_SUFFIXES2;
|
|
476
|
+
const schemas = [];
|
|
477
|
+
const exportedTypes = /* @__PURE__ */ new Set();
|
|
478
|
+
return {
|
|
479
|
+
ExportNamedDeclaration(node) {
|
|
480
|
+
const decl = node.declaration;
|
|
481
|
+
if (!decl) return;
|
|
482
|
+
if (decl.type === "VariableDeclaration") {
|
|
483
|
+
for (const d of decl.declarations) {
|
|
484
|
+
if (d.id.type === "Identifier" && d.init && rootIdentifierName(d.init) === "z") {
|
|
485
|
+
if (hasRoleSuffix(d.id.name, roleSuffixes)) continue;
|
|
486
|
+
if (!SCHEMA_NAME.test(d.id.name)) {
|
|
487
|
+
context.report({
|
|
488
|
+
node: d.id,
|
|
489
|
+
messageId: "schemaNaming",
|
|
490
|
+
data: { name: d.id.name }
|
|
491
|
+
});
|
|
492
|
+
} else {
|
|
493
|
+
schemas.push({ node: d.id, name: d.id.name });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} else if (decl.type === "TSTypeAliasDeclaration" || decl.type === "TSInterfaceDeclaration") {
|
|
498
|
+
exportedTypes.add(decl.id.name);
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
"Program:exit"() {
|
|
502
|
+
for (const schema of schemas) {
|
|
503
|
+
const base = schema.name.slice(0, -SUFFIX.length);
|
|
504
|
+
if (!exportedTypes.has(base)) {
|
|
505
|
+
context.report({
|
|
506
|
+
node: schema.node,
|
|
507
|
+
messageId: "missingType",
|
|
508
|
+
data: { name: schema.name, base }
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// src/rules/index.ts
|
|
518
|
+
var rules = {
|
|
519
|
+
"zod-schema-naming": zodSchemaNamingRule,
|
|
520
|
+
"wire-message-naming": wireMessageNamingRule,
|
|
521
|
+
"no-error-stringify": noErrorStringifyRule,
|
|
522
|
+
"no-direct-process-env": noDirectProcessEnvRule,
|
|
523
|
+
"money-must-be-decimal": moneyMustBeDecimalRule
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// src/index.ts
|
|
527
|
+
var NAMESPACE = "noctcore-contracts";
|
|
528
|
+
var VERSION = "0.1.0";
|
|
529
|
+
var plugin = {
|
|
530
|
+
meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
|
|
531
|
+
rules,
|
|
532
|
+
configs: {}
|
|
533
|
+
};
|
|
534
|
+
plugin.configs.recommended = {
|
|
535
|
+
plugins: { [NAMESPACE]: plugin },
|
|
536
|
+
rules: recommended
|
|
537
|
+
};
|
|
538
|
+
var configs = plugin.configs;
|
|
539
|
+
var index_default = plugin;
|
|
540
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
541
|
+
0 && (module.exports = {
|
|
542
|
+
configs,
|
|
543
|
+
rules
|
|
544
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
|
|
3
|
+
interface MoneyMustBeDecimalOptions {
|
|
4
|
+
/** Name of the domain money type to require (e.g. `Decimal`, `Money`, `BigDecimal`). */
|
|
5
|
+
readonly decimalType?: string;
|
|
6
|
+
/** Regex fragments (case-insensitive) identifying money-named fields. */
|
|
7
|
+
readonly fieldPatterns?: readonly string[];
|
|
8
|
+
/** Path-suffix allowlist of files to skip entirely. */
|
|
9
|
+
readonly allowedFiles?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface NoDirectProcessEnvOptions {
|
|
13
|
+
/** Import path of the typed config accessor consumers should read env through. */
|
|
14
|
+
readonly configModule?: string;
|
|
15
|
+
/** Glob allowlist of files permitted to read `process.env` directly (bootstrap, config, tests). */
|
|
16
|
+
readonly allowedFiles?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface NoErrorStringifyOptions {
|
|
20
|
+
readonly errorIdentifierNames?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface WireMessageNamingOptions {
|
|
24
|
+
readonly roleSuffixes?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ZodSchemaNamingOptions {
|
|
28
|
+
readonly roleSuffixes?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
|
|
32
|
+
declare const rules: {
|
|
33
|
+
'zod-schema-naming': _typescript_eslint_utils_ts_eslint.RuleModule<"schemaNaming" | "missingType", [ZodSchemaNamingOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
'wire-message-naming': _typescript_eslint_utils_ts_eslint.RuleModule<"typeMismatch", [WireMessageNamingOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
37
|
+
name: string;
|
|
38
|
+
};
|
|
39
|
+
'no-error-stringify': _typescript_eslint_utils_ts_eslint.RuleModule<"noErrorStringify", [NoErrorStringifyOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
40
|
+
name: string;
|
|
41
|
+
};
|
|
42
|
+
'no-direct-process-env': _typescript_eslint_utils_ts_eslint.RuleModule<"directProcessEnv", [NoDirectProcessEnvOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
'money-must-be-decimal': _typescript_eslint_utils_ts_eslint.RuleModule<"moneyMustBeDecimal", [MoneyMustBeDecimalOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
46
|
+
name: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
declare const plugin: {
|
|
51
|
+
meta: {
|
|
52
|
+
name: string;
|
|
53
|
+
version: string;
|
|
54
|
+
};
|
|
55
|
+
rules: {
|
|
56
|
+
'zod-schema-naming': _typescript_eslint_utils_ts_eslint.RuleModule<"schemaNaming" | "missingType", [ZodSchemaNamingOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
57
|
+
name: string;
|
|
58
|
+
};
|
|
59
|
+
'wire-message-naming': _typescript_eslint_utils_ts_eslint.RuleModule<"typeMismatch", [WireMessageNamingOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
60
|
+
name: string;
|
|
61
|
+
};
|
|
62
|
+
'no-error-stringify': _typescript_eslint_utils_ts_eslint.RuleModule<"noErrorStringify", [NoErrorStringifyOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
63
|
+
name: string;
|
|
64
|
+
};
|
|
65
|
+
'no-direct-process-env': _typescript_eslint_utils_ts_eslint.RuleModule<"directProcessEnv", [NoDirectProcessEnvOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
66
|
+
name: string;
|
|
67
|
+
};
|
|
68
|
+
'money-must-be-decimal': _typescript_eslint_utils_ts_eslint.RuleModule<"moneyMustBeDecimal", [MoneyMustBeDecimalOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
69
|
+
name: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
configs: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
declare const configs: Record<string, unknown>;
|
|
76
|
+
|
|
77
|
+
export { configs, plugin as default, rules };
|