@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.
@@ -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 };
package/dist/index.js ADDED
@@ -0,0 +1,516 @@
1
+ // src/configs/recommended.ts
2
+ var recommended = {
3
+ "noctcore-contracts/zod-schema-naming": "error",
4
+ "noctcore-contracts/wire-message-naming": "error",
5
+ "noctcore-contracts/no-error-stringify": "error",
6
+ "noctcore-contracts/no-direct-process-env": "error",
7
+ "noctcore-contracts/money-must-be-decimal": "error"
8
+ };
9
+
10
+ // src/rules/money-must-be-decimal.ts
11
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
12
+
13
+ // src/createRule.ts
14
+ import { makeCreateRule } from "@noctcore/eslint-utils";
15
+ var createRule = makeCreateRule("contracts");
16
+
17
+ // src/rules/money-must-be-decimal.ts
18
+ var RULE_NAME = "money-must-be-decimal";
19
+ var DEFAULT_DECIMAL_TYPE = "Decimal";
20
+ var DEFAULT_FIELD_PATTERNS = [
21
+ "amount",
22
+ "price",
23
+ "cost",
24
+ "total",
25
+ "balance"
26
+ ];
27
+ var DEFAULT_ALLOWED_FILES = [];
28
+ var optionSchema = {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ properties: {
32
+ decimalType: { type: "string", minLength: 1 },
33
+ fieldPatterns: {
34
+ type: "array",
35
+ items: { type: "string" },
36
+ uniqueItems: true,
37
+ minItems: 1
38
+ },
39
+ allowedFiles: {
40
+ type: "array",
41
+ items: { type: "string" },
42
+ uniqueItems: true
43
+ }
44
+ }
45
+ };
46
+ function toForwardSlash(filename) {
47
+ return filename.split("\\").join("/");
48
+ }
49
+ function isAllowedFile(filename, patterns) {
50
+ if (patterns.length === 0) {
51
+ return false;
52
+ }
53
+ const normalized = toForwardSlash(filename);
54
+ return patterns.some((pattern) => normalized.endsWith(toForwardSlash(pattern)));
55
+ }
56
+ function staticName(node) {
57
+ if (node.type === AST_NODE_TYPES.Identifier) {
58
+ return node.name;
59
+ }
60
+ return void 0;
61
+ }
62
+ function isNumberAnnotation(annotation) {
63
+ return annotation?.typeAnnotation.type === AST_NODE_TYPES.TSNumberKeyword;
64
+ }
65
+ var moneyMustBeDecimalRule = createRule({
66
+ name: RULE_NAME,
67
+ meta: {
68
+ type: "problem",
69
+ docs: {
70
+ 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."
71
+ },
72
+ schema: [optionSchema],
73
+ messages: {
74
+ 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."
75
+ }
76
+ },
77
+ defaultOptions: [
78
+ {
79
+ decimalType: DEFAULT_DECIMAL_TYPE,
80
+ fieldPatterns: [...DEFAULT_FIELD_PATTERNS],
81
+ allowedFiles: []
82
+ }
83
+ ],
84
+ create(context, [options]) {
85
+ const decimalType = options.decimalType ?? DEFAULT_DECIMAL_TYPE;
86
+ const allowedFiles = options.allowedFiles ?? DEFAULT_ALLOWED_FILES;
87
+ if (isAllowedFile(context.filename, allowedFiles)) {
88
+ return {};
89
+ }
90
+ const fieldPatterns = options.fieldPatterns ?? DEFAULT_FIELD_PATTERNS;
91
+ const moneyPattern = new RegExp(`(${fieldPatterns.join("|")})`, "i");
92
+ function report(node) {
93
+ context.report({ node, messageId: "moneyMustBeDecimal", data: { decimalType } });
94
+ }
95
+ return {
96
+ // `class Invoice { total: number }`: class field explicitly typed number.
97
+ PropertyDefinition(node) {
98
+ if (node.computed) {
99
+ return;
100
+ }
101
+ const name = staticName(node.key);
102
+ if (name !== void 0 && moneyPattern.test(name) && isNumberAnnotation(node.typeAnnotation)) {
103
+ report(node);
104
+ }
105
+ },
106
+ // `const total: number = ...`: annotated variable declarator.
107
+ VariableDeclarator(node) {
108
+ if (node.id.type !== AST_NODE_TYPES.Identifier) {
109
+ return;
110
+ }
111
+ const name = node.id.name;
112
+ if (moneyPattern.test(name) && isNumberAnnotation(node.id.typeAnnotation)) {
113
+ report(node);
114
+ }
115
+ }
116
+ };
117
+ }
118
+ });
119
+
120
+ // src/rules/no-direct-process-env.ts
121
+ import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
122
+ var RULE_NAME2 = "no-direct-process-env";
123
+ var DEFAULT_CONFIG_MODULE = "@/config";
124
+ var DEFAULT_ALLOWED_FILES2 = [
125
+ "**/*.config.{ts,js,mjs,cjs}",
126
+ "**/*.{spec,test}.{ts,tsx}",
127
+ "**/scripts/**"
128
+ ];
129
+ var optionSchema2 = {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ properties: {
133
+ configModule: { type: "string", minLength: 1 },
134
+ allowedFiles: {
135
+ type: "array",
136
+ items: { type: "string" },
137
+ uniqueItems: true
138
+ }
139
+ }
140
+ };
141
+ function globToRegExp(glob) {
142
+ let re = "";
143
+ let braceDepth = 0;
144
+ for (let i = 0; i < glob.length; i++) {
145
+ const c = glob[i];
146
+ if (c === "*") {
147
+ if (glob[i + 1] === "*") {
148
+ i++;
149
+ if (glob[i + 1] === "/") {
150
+ i++;
151
+ re += "(?:.*/)?";
152
+ } else {
153
+ re += ".*";
154
+ }
155
+ } else {
156
+ re += "[^/]*";
157
+ }
158
+ } else if (c === "?") {
159
+ re += "[^/]";
160
+ } else if (c === "{") {
161
+ re += "(?:";
162
+ braceDepth++;
163
+ } else if (c === "}") {
164
+ re += ")";
165
+ if (braceDepth > 0) braceDepth--;
166
+ } else if (c === ",") {
167
+ re += braceDepth > 0 ? "|" : "\\,";
168
+ } else if (".+^$()|[]\\/".includes(c)) {
169
+ re += `\\${c}`;
170
+ } else {
171
+ re += c;
172
+ }
173
+ }
174
+ return new RegExp(`^${re}$`);
175
+ }
176
+ function isAllowedFile2(filename, patterns) {
177
+ if (patterns.length === 0) {
178
+ return false;
179
+ }
180
+ const normalized = filename.split("\\").join("/");
181
+ return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
182
+ }
183
+ function isProcessEnv(node) {
184
+ if (node.type !== AST_NODE_TYPES2.MemberExpression || node.object.type !== AST_NODE_TYPES2.Identifier || node.object.name !== "process") {
185
+ return false;
186
+ }
187
+ if (node.computed) {
188
+ return node.property.type === AST_NODE_TYPES2.Literal && node.property.value === "env";
189
+ }
190
+ return node.property.type === AST_NODE_TYPES2.Identifier && node.property.name === "env";
191
+ }
192
+ var noDirectProcessEnvRule = createRule({
193
+ name: RULE_NAME2,
194
+ meta: {
195
+ type: "problem",
196
+ docs: {
197
+ 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."
198
+ },
199
+ schema: [optionSchema2],
200
+ messages: {
201
+ directProcessEnv: "Read environment variables through your typed config accessor (import from `{{configModule}}`). Direct `process.env` access bypasses boot-time validation."
202
+ }
203
+ },
204
+ defaultOptions: [
205
+ {
206
+ configModule: DEFAULT_CONFIG_MODULE,
207
+ allowedFiles: [...DEFAULT_ALLOWED_FILES2]
208
+ }
209
+ ],
210
+ create(context, [options]) {
211
+ const configModule = options.configModule ?? DEFAULT_CONFIG_MODULE;
212
+ const allowedFiles = options.allowedFiles ?? DEFAULT_ALLOWED_FILES2;
213
+ if (isAllowedFile2(context.filename, allowedFiles)) {
214
+ return {};
215
+ }
216
+ return {
217
+ /*
218
+ * `process.env` is itself a MemberExpression, so a single visitor on the
219
+ * node catches every usage position: property access (`process.env.X`),
220
+ * computed access (`process.env[X]`), destructuring (`const { X } =
221
+ * process.env`), and bare value usage where it is passed as an argument,
222
+ * returned, or assigned (`log(process.env)`, `return process.env`).
223
+ */
224
+ MemberExpression(node) {
225
+ if (isProcessEnv(node)) {
226
+ context.report({ node, messageId: "directProcessEnv", data: { configModule } });
227
+ }
228
+ }
229
+ };
230
+ }
231
+ });
232
+
233
+ // src/rules/no-error-stringify.ts
234
+ import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
235
+ var RULE_NAME3 = "no-error-stringify";
236
+ var DEFAULT_ERROR_NAMES = ["error", "err", "e", "cause"];
237
+ var optionSchema3 = {
238
+ type: "object",
239
+ additionalProperties: false,
240
+ properties: {
241
+ errorIdentifierNames: {
242
+ type: "array",
243
+ items: { type: "string" },
244
+ uniqueItems: true,
245
+ minItems: 1
246
+ }
247
+ }
248
+ };
249
+ function isEmptyStringLiteral(node) {
250
+ return node.type === AST_NODE_TYPES3.Literal && node.value === "";
251
+ }
252
+ function isErrorIdentifier(node, names) {
253
+ return node.type === AST_NODE_TYPES3.Identifier && names.has(node.name);
254
+ }
255
+ var noErrorStringifyRule = createRule({
256
+ name: RULE_NAME3,
257
+ meta: {
258
+ type: "problem",
259
+ docs: {
260
+ 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.'
261
+ },
262
+ schema: [optionSchema3],
263
+ messages: {
264
+ 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)."
265
+ }
266
+ },
267
+ defaultOptions: [{ errorIdentifierNames: [...DEFAULT_ERROR_NAMES] }],
268
+ create(context, [options]) {
269
+ const errorNames = new Set(options.errorIdentifierNames ?? DEFAULT_ERROR_NAMES);
270
+ function report(node, name) {
271
+ context.report({ node, messageId: "noErrorStringify", data: { name } });
272
+ }
273
+ return {
274
+ // `error.toString()`
275
+ 'CallExpression[callee.type="MemberExpression"]'(node) {
276
+ const callee = node.callee;
277
+ if (!callee.computed && callee.property.type === AST_NODE_TYPES3.Identifier && callee.property.name === "toString" && node.arguments.length === 0 && isErrorIdentifier(callee.object, errorNames)) {
278
+ report(node, callee.object.name);
279
+ }
280
+ },
281
+ // bare `${error}` inside a template literal
282
+ TemplateLiteral(node) {
283
+ for (const expr of node.expressions) {
284
+ if (isErrorIdentifier(expr, errorNames)) {
285
+ report(expr, expr.name);
286
+ }
287
+ }
288
+ },
289
+ // `error + ""` or `"" + error`
290
+ BinaryExpression(node) {
291
+ if (node.operator !== "+") {
292
+ return;
293
+ }
294
+ const sides = [node.left, node.right];
295
+ if (!sides.some(isEmptyStringLiteral)) {
296
+ return;
297
+ }
298
+ for (const side of sides) {
299
+ if (isErrorIdentifier(side, errorNames)) {
300
+ report(node, side.name);
301
+ return;
302
+ }
303
+ }
304
+ }
305
+ };
306
+ }
307
+ });
308
+
309
+ // src/rules/wire-message-naming.ts
310
+ var RULE_NAME4 = "wire-message-naming";
311
+ var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
312
+ var optionSchema4 = {
313
+ type: "object",
314
+ additionalProperties: false,
315
+ properties: {
316
+ roleSuffixes: {
317
+ type: "array",
318
+ items: { type: "string" },
319
+ uniqueItems: true,
320
+ minItems: 1
321
+ }
322
+ }
323
+ };
324
+ function kebab(input) {
325
+ return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").toLowerCase();
326
+ }
327
+ function roleSuffix(name, suffixes) {
328
+ return suffixes.find((s) => name.endsWith(s) && name.length > s.length) ?? null;
329
+ }
330
+ function unwrapToObject(node) {
331
+ let cur = node;
332
+ while (cur && cur.type === "CallExpression") {
333
+ const objArg = cur.arguments.find((a) => a.type === "ObjectExpression");
334
+ if (objArg && objArg.type === "ObjectExpression") return objArg;
335
+ cur = cur.callee.type === "MemberExpression" ? cur.callee.object : null;
336
+ }
337
+ return null;
338
+ }
339
+ function typeLiteralNode(obj) {
340
+ for (const p of obj.properties) {
341
+ if (p.type !== "Property") continue;
342
+ const isType = p.key.type === "Identifier" && p.key.name === "type" || p.key.type === "Literal" && p.key.value === "type";
343
+ if (!isType) continue;
344
+ const v = p.value;
345
+ if (v.type === "CallExpression" && v.callee.type === "MemberExpression" && v.callee.property.type === "Identifier" && v.callee.property.name === "literal") {
346
+ const arg = v.arguments[0];
347
+ if (arg && arg.type === "Literal" && typeof arg.value === "string") return arg;
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+ var wireMessageNamingRule = createRule({
353
+ name: RULE_NAME4,
354
+ meta: {
355
+ type: "problem",
356
+ docs: {
357
+ 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)."
358
+ },
359
+ fixable: "code",
360
+ schema: [optionSchema4],
361
+ messages: {
362
+ typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
363
+ }
364
+ },
365
+ defaultOptions: [{ roleSuffixes: ["Event", "Command", "Query"] }],
366
+ create(context, [options]) {
367
+ const roleSuffixes = options.roleSuffixes ?? DEFAULT_ROLE_SUFFIXES;
368
+ return {
369
+ ExportNamedDeclaration(node) {
370
+ const decl = node.declaration;
371
+ if (!decl || decl.type !== "VariableDeclaration") return;
372
+ for (const d of decl.declarations) {
373
+ if (d.id.type !== "Identifier" || !d.init) continue;
374
+ const suffix = roleSuffix(d.id.name, roleSuffixes);
375
+ if (!suffix) continue;
376
+ const obj = unwrapToObject(d.init);
377
+ if (!obj) continue;
378
+ const lit = typeLiteralNode(obj);
379
+ if (!lit) continue;
380
+ const expected = kebab(d.id.name.slice(0, -suffix.length));
381
+ if (lit.value !== expected) {
382
+ context.report({
383
+ node: lit,
384
+ messageId: "typeMismatch",
385
+ data: { name: d.id.name, actual: String(lit.value), expected },
386
+ fix: (fixer) => fixer.replaceText(lit, `'${expected}'`)
387
+ });
388
+ }
389
+ }
390
+ }
391
+ };
392
+ }
393
+ });
394
+
395
+ // src/rules/zod-schema-naming.ts
396
+ var RULE_NAME5 = "zod-schema-naming";
397
+ var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
398
+ var SUFFIX = "Schema";
399
+ var DEFAULT_ROLE_SUFFIXES2 = [];
400
+ var optionSchema5 = {
401
+ type: "object",
402
+ additionalProperties: false,
403
+ properties: {
404
+ roleSuffixes: {
405
+ type: "array",
406
+ items: { type: "string" },
407
+ uniqueItems: true
408
+ }
409
+ }
410
+ };
411
+ function hasRoleSuffix(name, suffixes) {
412
+ return suffixes.some((s) => name.endsWith(s) && name.length > s.length);
413
+ }
414
+ function rootIdentifierName(node) {
415
+ let current = node;
416
+ while (current) {
417
+ switch (current.type) {
418
+ case "CallExpression":
419
+ current = current.callee;
420
+ break;
421
+ case "MemberExpression":
422
+ current = current.object;
423
+ break;
424
+ case "Identifier":
425
+ return current.name;
426
+ default:
427
+ return null;
428
+ }
429
+ }
430
+ return null;
431
+ }
432
+ var zodSchemaNamingRule = createRule({
433
+ name: RULE_NAME5,
434
+ meta: {
435
+ type: "problem",
436
+ docs: {
437
+ 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>`)."
438
+ },
439
+ schema: [optionSchema5],
440
+ messages: {
441
+ schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
442
+ missingType: "Schema `{{name}}` has no sibling `export type {{base}} = z.infer<typeof {{name}}>`. Export the inferred type instead of hand-authoring a duplicate."
443
+ }
444
+ },
445
+ defaultOptions: [{ roleSuffixes: [] }],
446
+ create(context, [options]) {
447
+ const roleSuffixes = options.roleSuffixes ?? DEFAULT_ROLE_SUFFIXES2;
448
+ const schemas = [];
449
+ const exportedTypes = /* @__PURE__ */ new Set();
450
+ return {
451
+ ExportNamedDeclaration(node) {
452
+ const decl = node.declaration;
453
+ if (!decl) return;
454
+ if (decl.type === "VariableDeclaration") {
455
+ for (const d of decl.declarations) {
456
+ if (d.id.type === "Identifier" && d.init && rootIdentifierName(d.init) === "z") {
457
+ if (hasRoleSuffix(d.id.name, roleSuffixes)) continue;
458
+ if (!SCHEMA_NAME.test(d.id.name)) {
459
+ context.report({
460
+ node: d.id,
461
+ messageId: "schemaNaming",
462
+ data: { name: d.id.name }
463
+ });
464
+ } else {
465
+ schemas.push({ node: d.id, name: d.id.name });
466
+ }
467
+ }
468
+ }
469
+ } else if (decl.type === "TSTypeAliasDeclaration" || decl.type === "TSInterfaceDeclaration") {
470
+ exportedTypes.add(decl.id.name);
471
+ }
472
+ },
473
+ "Program:exit"() {
474
+ for (const schema of schemas) {
475
+ const base = schema.name.slice(0, -SUFFIX.length);
476
+ if (!exportedTypes.has(base)) {
477
+ context.report({
478
+ node: schema.node,
479
+ messageId: "missingType",
480
+ data: { name: schema.name, base }
481
+ });
482
+ }
483
+ }
484
+ }
485
+ };
486
+ }
487
+ });
488
+
489
+ // src/rules/index.ts
490
+ var rules = {
491
+ "zod-schema-naming": zodSchemaNamingRule,
492
+ "wire-message-naming": wireMessageNamingRule,
493
+ "no-error-stringify": noErrorStringifyRule,
494
+ "no-direct-process-env": noDirectProcessEnvRule,
495
+ "money-must-be-decimal": moneyMustBeDecimalRule
496
+ };
497
+
498
+ // src/index.ts
499
+ var NAMESPACE = "noctcore-contracts";
500
+ var VERSION = "0.1.0";
501
+ var plugin = {
502
+ meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
503
+ rules,
504
+ configs: {}
505
+ };
506
+ plugin.configs.recommended = {
507
+ plugins: { [NAMESPACE]: plugin },
508
+ rules: recommended
509
+ };
510
+ var configs = plugin.configs;
511
+ var index_default = plugin;
512
+ export {
513
+ configs,
514
+ index_default as default,
515
+ rules
516
+ };
@@ -0,0 +1,52 @@
1
+ # `noctcore-contracts/money-must-be-decimal`
2
+
3
+ > Monetary fields typed as the JS `number` primitive lose precision to float rounding — use a Decimal money type.
4
+
5
+ ## Why
6
+
7
+ Money stored as a JS `number` accumulates IEEE-754 rounding errors (`0.1 + 0.2 !== 0.3`), which is
8
+ unacceptable for accounting figures. This rule is a **dormant guard**: it stays green on a codebase
9
+ with no money fields and lights up the moment a money-named field is introduced with the wrong type.
10
+
11
+ ## What it flags
12
+
13
+ A field whose name matches the money pattern **and** is explicitly annotated `: number`, in the two
14
+ positions that carry real precision risk:
15
+
16
+ - class properties — `class Invoice { total: number }`
17
+ - annotated variable declarators — `const amount: number = …`
18
+
19
+ Conservative on purpose. Untyped declarations and numeric-literal initializers (`let total = 0`) are
20
+ **not** flagged — those are usually counters/accumulators. Interface and type-literal members
21
+ (`{ amount: number }`) are **out of scope** so non-money type members do not regress.
22
+
23
+ ```ts
24
+ // ✗
25
+ class Invoice { total: number; }
26
+ const amount: number = 5;
27
+
28
+ // ✓
29
+ class Invoice { total: Decimal; }
30
+ const count: number = 3; // not a money name
31
+ interface Payment { amount: number; } // type member, out of scope
32
+ ```
33
+
34
+ ## Options
35
+
36
+ | Option | Type | Default | Meaning |
37
+ | --- | --- | --- | --- |
38
+ | `decimalType` | `string` | `'Decimal'` | Name of the money type to require; appears in the report message. |
39
+ | `fieldPatterns` | `string[]` | `['amount', 'price', 'cost', 'total', 'balance']` | Case-insensitive regex fragments identifying money-named fields (OR-combined). |
40
+ | `allowedFiles` | `string[]` | `[]` | Path-suffix allowlist of files skipped entirely (e.g. `apps/api/src/legacy/totals.ts`). |
41
+
42
+ ```js
43
+ 'noctcore-contracts/money-must-be-decimal': ['error', {
44
+ decimalType: 'Money',
45
+ fieldPatterns: ['amount', 'price', 'discount', 'vat'],
46
+ }]
47
+ ```
48
+
49
+ ## When not to use it
50
+
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.
@@ -0,0 +1,51 @@
1
+ # `noctcore-contracts/no-direct-process-env`
2
+
3
+ > Read environment variables through a typed, validated config accessor — never `process.env` directly.
4
+
5
+ ## Why
6
+
7
+ `process.env.X` is `string | undefined`, unvalidated, and reachable from anywhere. A typo or a missing
8
+ variable fails silently at the point of use, often deep in a request. Funnelling every read through a
9
+ single typed config module makes the environment a validated contract that fails **at boot**, and
10
+ gives every consumer real types.
11
+
12
+ ## What it flags
13
+
14
+ Any `process.env` access, in every position — property read (`process.env.X`), computed
15
+ (`process.env[X]`), destructure (`const { X } = process.env`), or the bare value passed / returned /
16
+ assigned (`log(process.env)`, `return process.env`). Computed `process['env']` cannot bypass it.
17
+
18
+ ```ts
19
+ // ✗
20
+ const isProd = process.env.NODE_ENV === 'production';
21
+ const { DATABASE_URL } = process.env;
22
+ const env = process['env'];
23
+
24
+ // ✓
25
+ const isProd = config.isProduction;
26
+ ```
27
+
28
+ Files matched by the `allowedFiles` glob allowlist are skipped entirely, so bootstrap entrypoints,
29
+ config files, and tests may still read `process.env` directly.
30
+
31
+ ## Options
32
+
33
+ | Option | Type | Default | Meaning |
34
+ | --- | --- | --- | --- |
35
+ | `configModule` | `string` | `'@/config'` | Import path of the typed config accessor, named in the report message. |
36
+ | `allowedFiles` | `string[]` (globs) | `['**/*.config.{ts,js,mjs,cjs}', '**/*.{spec,test}.{ts,tsx}', '**/scripts/**']` | Files permitted to read `process.env` directly. |
37
+
38
+ Globs support `*`, `**`, `?`, and `{a,b}` alternation; a leading `**/` matches any (or no) directory
39
+ prefix, so patterns work against both relative and absolute filenames.
40
+
41
+ ```js
42
+ 'noctcore-contracts/no-direct-process-env': ['error', {
43
+ configModule: '@acme/config',
44
+ allowedFiles: ['**/apps/server/**', '**/*.{spec,test}.{ts,tsx}'],
45
+ }]
46
+ ```
47
+
48
+ ## When not to use it
49
+
50
+ If you have no config seam yet, or intentionally read `process.env` throughout (a small script, a
51
+ Vite-style `import.meta.env` app), leave this rule off or widen `allowedFiles`.