@noctcore/eslint-plugin-contracts 0.1.0 → 0.2.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.js CHANGED
@@ -4,18 +4,118 @@ var recommended = {
4
4
  "noctcore-contracts/wire-message-naming": "error",
5
5
  "noctcore-contracts/no-error-stringify": "error",
6
6
  "noctcore-contracts/no-direct-process-env": "error",
7
- "noctcore-contracts/money-must-be-decimal": "error"
7
+ "noctcore-contracts/money-must-be-decimal": "error",
8
+ "noctcore-contracts/require-error-cause": "error",
9
+ "noctcore-contracts/restrict-throw-to-taxonomy": "error",
10
+ // Config-required / heuristic rules ship inert. `require-registered-keys` and
11
+ // `env-var-schema-parity` do nothing until their `sinks` / `schema` options are
12
+ // set; `require-schema-parse-at-boundary` is a conservative syntactic slice of a
13
+ // type-aware concern. Enable them explicitly once configured for your project.
14
+ "noctcore-contracts/require-registered-keys": "off",
15
+ "noctcore-contracts/env-var-schema-parity": "off",
16
+ "noctcore-contracts/require-schema-parse-at-boundary": "off"
8
17
  };
9
18
 
10
- // src/rules/money-must-be-decimal.ts
19
+ // src/rules/env-var-schema-parity.ts
20
+ import { readFileSync } from "fs";
21
+ import path from "path";
11
22
  import { AST_NODE_TYPES } from "@typescript-eslint/utils";
12
23
 
13
24
  // src/createRule.ts
14
25
  import { makeCreateRule } from "@noctcore/eslint-utils";
15
26
  var createRule = makeCreateRule("contracts");
16
27
 
28
+ // src/rules/env-var-schema-parity.ts
29
+ var RULE_NAME = "env-var-schema-parity";
30
+ var optionSchema = {
31
+ type: "object",
32
+ additionalProperties: false,
33
+ properties: {
34
+ schema: { type: "string", minLength: 1 }
35
+ }
36
+ };
37
+ var schemaCache = /* @__PURE__ */ new Map();
38
+ var DOTENV_KEY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm;
39
+ var OBJECT_KEY = /["'`]?([A-Za-z_][A-Za-z0-9_]*)["'`]?\s*:/g;
40
+ function parseDeclaredKeys(source) {
41
+ const keys = /* @__PURE__ */ new Set();
42
+ for (const match of source.matchAll(DOTENV_KEY)) {
43
+ if (match[1]) keys.add(match[1]);
44
+ }
45
+ for (const match of source.matchAll(OBJECT_KEY)) {
46
+ if (match[1]) keys.add(match[1]);
47
+ }
48
+ return keys;
49
+ }
50
+ function loadSchema(cwd, schema) {
51
+ const resolved = path.isAbsolute(schema) ? schema : path.resolve(cwd, schema);
52
+ const cached = schemaCache.get(resolved);
53
+ if (cached !== void 0) {
54
+ return cached;
55
+ }
56
+ let keys;
57
+ try {
58
+ keys = parseDeclaredKeys(readFileSync(resolved, "utf8"));
59
+ } catch {
60
+ keys = null;
61
+ }
62
+ schemaCache.set(resolved, keys);
63
+ return keys;
64
+ }
65
+ function isProcessEnv(node) {
66
+ return node.type === AST_NODE_TYPES.MemberExpression && !node.computed && node.object.type === AST_NODE_TYPES.Identifier && node.object.name === "process" && node.property.type === AST_NODE_TYPES.Identifier && node.property.name === "env";
67
+ }
68
+ function isImportMetaEnv(node) {
69
+ return node.type === AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES.Identifier && node.property.name === "env" && node.object.type === AST_NODE_TYPES.MetaProperty && node.object.meta.name === "import" && node.object.property.name === "meta";
70
+ }
71
+ var envVarSchemaParityRule = createRule({
72
+ name: RULE_NAME,
73
+ meta: {
74
+ type: "suggestion",
75
+ docs: {
76
+ description: "Require every `process.env.FOO` / `import.meta.env.FOO` key to be declared in a schema file (`.env.example` or a zod-env module), so config access and config declaration cannot drift apart."
77
+ },
78
+ schema: [optionSchema],
79
+ messages: {
80
+ undeclaredEnvVar: "Env var `{{name}}` is read here but not declared in `{{schema}}`. Add it to the schema (or fix the typo) so config stays a validated contract."
81
+ }
82
+ },
83
+ defaultOptions: [{}],
84
+ create(context, [options]) {
85
+ const schema = options.schema;
86
+ if (!schema) {
87
+ return {};
88
+ }
89
+ const declared = loadSchema(context.cwd, schema);
90
+ if (declared === null) {
91
+ return {};
92
+ }
93
+ function check(node) {
94
+ if (node.computed || node.property.type !== AST_NODE_TYPES.Identifier) {
95
+ return;
96
+ }
97
+ const name = node.property.name;
98
+ if (!declared.has(name)) {
99
+ context.report({
100
+ node: node.property,
101
+ messageId: "undeclaredEnvVar",
102
+ data: { name, schema }
103
+ });
104
+ }
105
+ }
106
+ return {
107
+ MemberExpression(node) {
108
+ if (isProcessEnv(node.object) || isImportMetaEnv(node.object)) {
109
+ check(node);
110
+ }
111
+ }
112
+ };
113
+ }
114
+ });
115
+
17
116
  // src/rules/money-must-be-decimal.ts
18
- var RULE_NAME = "money-must-be-decimal";
117
+ import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
118
+ var RULE_NAME2 = "money-must-be-decimal";
19
119
  var DEFAULT_DECIMAL_TYPE = "Decimal";
20
120
  var DEFAULT_FIELD_PATTERNS = [
21
121
  "amount",
@@ -25,7 +125,7 @@ var DEFAULT_FIELD_PATTERNS = [
25
125
  "balance"
26
126
  ];
27
127
  var DEFAULT_ALLOWED_FILES = [];
28
- var optionSchema = {
128
+ var optionSchema2 = {
29
129
  type: "object",
30
130
  additionalProperties: false,
31
131
  properties: {
@@ -54,22 +154,22 @@ function isAllowedFile(filename, patterns) {
54
154
  return patterns.some((pattern) => normalized.endsWith(toForwardSlash(pattern)));
55
155
  }
56
156
  function staticName(node) {
57
- if (node.type === AST_NODE_TYPES.Identifier) {
157
+ if (node.type === AST_NODE_TYPES2.Identifier) {
58
158
  return node.name;
59
159
  }
60
160
  return void 0;
61
161
  }
62
162
  function isNumberAnnotation(annotation) {
63
- return annotation?.typeAnnotation.type === AST_NODE_TYPES.TSNumberKeyword;
163
+ return annotation?.typeAnnotation.type === AST_NODE_TYPES2.TSNumberKeyword;
64
164
  }
65
165
  var moneyMustBeDecimalRule = createRule({
66
- name: RULE_NAME,
166
+ name: RULE_NAME2,
67
167
  meta: {
68
168
  type: "problem",
69
169
  docs: {
70
170
  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
171
  },
72
- schema: [optionSchema],
172
+ schema: [optionSchema2],
73
173
  messages: {
74
174
  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
175
  }
@@ -105,7 +205,7 @@ var moneyMustBeDecimalRule = createRule({
105
205
  },
106
206
  // `const total: number = ...`: annotated variable declarator.
107
207
  VariableDeclarator(node) {
108
- if (node.id.type !== AST_NODE_TYPES.Identifier) {
208
+ if (node.id.type !== AST_NODE_TYPES2.Identifier) {
109
209
  return;
110
210
  }
111
211
  const name = node.id.name;
@@ -118,15 +218,15 @@ var moneyMustBeDecimalRule = createRule({
118
218
  });
119
219
 
120
220
  // 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";
221
+ import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
222
+ var RULE_NAME3 = "no-direct-process-env";
123
223
  var DEFAULT_CONFIG_MODULE = "@/config";
124
224
  var DEFAULT_ALLOWED_FILES2 = [
125
225
  "**/*.config.{ts,js,mjs,cjs}",
126
226
  "**/*.{spec,test}.{ts,tsx}",
127
227
  "**/scripts/**"
128
228
  ];
129
- var optionSchema2 = {
229
+ var optionSchema3 = {
130
230
  type: "object",
131
231
  additionalProperties: false,
132
232
  properties: {
@@ -180,23 +280,23 @@ function isAllowedFile2(filename, patterns) {
180
280
  const normalized = filename.split("\\").join("/");
181
281
  return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
182
282
  }
183
- function isProcessEnv(node) {
184
- if (node.type !== AST_NODE_TYPES2.MemberExpression || node.object.type !== AST_NODE_TYPES2.Identifier || node.object.name !== "process") {
283
+ function isProcessEnv2(node) {
284
+ if (node.type !== AST_NODE_TYPES3.MemberExpression || node.object.type !== AST_NODE_TYPES3.Identifier || node.object.name !== "process") {
185
285
  return false;
186
286
  }
187
287
  if (node.computed) {
188
- return node.property.type === AST_NODE_TYPES2.Literal && node.property.value === "env";
288
+ return node.property.type === AST_NODE_TYPES3.Literal && node.property.value === "env";
189
289
  }
190
- return node.property.type === AST_NODE_TYPES2.Identifier && node.property.name === "env";
290
+ return node.property.type === AST_NODE_TYPES3.Identifier && node.property.name === "env";
191
291
  }
192
292
  var noDirectProcessEnvRule = createRule({
193
- name: RULE_NAME2,
293
+ name: RULE_NAME3,
194
294
  meta: {
195
295
  type: "problem",
196
296
  docs: {
197
297
  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
298
  },
199
- schema: [optionSchema2],
299
+ schema: [optionSchema3],
200
300
  messages: {
201
301
  directProcessEnv: "Read environment variables through your typed config accessor (import from `{{configModule}}`). Direct `process.env` access bypasses boot-time validation."
202
302
  }
@@ -222,7 +322,7 @@ var noDirectProcessEnvRule = createRule({
222
322
  * returned, or assigned (`log(process.env)`, `return process.env`).
223
323
  */
224
324
  MemberExpression(node) {
225
- if (isProcessEnv(node)) {
325
+ if (isProcessEnv2(node)) {
226
326
  context.report({ node, messageId: "directProcessEnv", data: { configModule } });
227
327
  }
228
328
  }
@@ -231,10 +331,10 @@ var noDirectProcessEnvRule = createRule({
231
331
  });
232
332
 
233
333
  // 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";
334
+ import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
335
+ var RULE_NAME4 = "no-error-stringify";
236
336
  var DEFAULT_ERROR_NAMES = ["error", "err", "e", "cause"];
237
- var optionSchema3 = {
337
+ var optionSchema4 = {
238
338
  type: "object",
239
339
  additionalProperties: false,
240
340
  properties: {
@@ -247,19 +347,19 @@ var optionSchema3 = {
247
347
  }
248
348
  };
249
349
  function isEmptyStringLiteral(node) {
250
- return node.type === AST_NODE_TYPES3.Literal && node.value === "";
350
+ return node.type === AST_NODE_TYPES4.Literal && node.value === "";
251
351
  }
252
352
  function isErrorIdentifier(node, names) {
253
- return node.type === AST_NODE_TYPES3.Identifier && names.has(node.name);
353
+ return node.type === AST_NODE_TYPES4.Identifier && names.has(node.name);
254
354
  }
255
355
  var noErrorStringifyRule = createRule({
256
- name: RULE_NAME3,
356
+ name: RULE_NAME4,
257
357
  meta: {
258
358
  type: "problem",
259
359
  docs: {
260
360
  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
361
  },
262
- schema: [optionSchema3],
362
+ schema: [optionSchema4],
263
363
  messages: {
264
364
  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
365
  }
@@ -274,7 +374,7 @@ var noErrorStringifyRule = createRule({
274
374
  // `error.toString()`
275
375
  'CallExpression[callee.type="MemberExpression"]'(node) {
276
376
  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)) {
377
+ if (!callee.computed && callee.property.type === AST_NODE_TYPES4.Identifier && callee.property.name === "toString" && node.arguments.length === 0 && isErrorIdentifier(callee.object, errorNames)) {
278
378
  report(node, callee.object.name);
279
379
  }
280
380
  },
@@ -306,10 +406,332 @@ var noErrorStringifyRule = createRule({
306
406
  }
307
407
  });
308
408
 
409
+ // src/rules/require-error-cause.ts
410
+ import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
411
+ var RULE_NAME5 = "require-error-cause";
412
+ function constructorSimpleName(node) {
413
+ const callee = node.callee;
414
+ if (callee.type === AST_NODE_TYPES5.Identifier) {
415
+ return callee.name;
416
+ }
417
+ if (callee.type === AST_NODE_TYPES5.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES5.Identifier) {
418
+ return callee.property.name;
419
+ }
420
+ return null;
421
+ }
422
+ function isErrorLikeName(name) {
423
+ return /(?:Error|Exception)$/.test(name);
424
+ }
425
+ function alreadyHasCause(node) {
426
+ for (const arg of node.arguments) {
427
+ if (arg.type === AST_NODE_TYPES5.SpreadElement) {
428
+ return true;
429
+ }
430
+ if (arg.type === AST_NODE_TYPES5.ObjectExpression) {
431
+ for (const prop of arg.properties) {
432
+ if (prop.type === AST_NODE_TYPES5.SpreadElement) {
433
+ return true;
434
+ }
435
+ const key = prop.key;
436
+ const isCause = key.type === AST_NODE_TYPES5.Identifier && key.name === "cause" || key.type === AST_NODE_TYPES5.Literal && key.value === "cause";
437
+ if (isCause) {
438
+ return true;
439
+ }
440
+ }
441
+ }
442
+ }
443
+ return false;
444
+ }
445
+ function buildFix(node, binding) {
446
+ const args = node.arguments;
447
+ if (args.length === 0) {
448
+ return null;
449
+ }
450
+ const last = args[args.length - 1];
451
+ if (last === void 0) {
452
+ return null;
453
+ }
454
+ if (last.type === AST_NODE_TYPES5.ObjectExpression) {
455
+ const props = last.properties;
456
+ if (props.length === 0) {
457
+ return (fixer) => fixer.replaceText(last, `{ cause: ${binding} }`);
458
+ }
459
+ const lastProp = props[props.length - 1];
460
+ if (lastProp === void 0) {
461
+ return null;
462
+ }
463
+ return (fixer) => fixer.insertTextAfter(lastProp, `, cause: ${binding}`);
464
+ }
465
+ return (fixer) => fixer.insertTextAfter(last, `, { cause: ${binding} }`);
466
+ }
467
+ var requireErrorCauseRule = createRule({
468
+ name: RULE_NAME5,
469
+ meta: {
470
+ type: "problem",
471
+ docs: {
472
+ description: "Require re-thrown errors inside a `catch` to forward the caught error as `{ cause }`. A `throw new SomeError(...)` that omits the cause severs the chain to the original failure."
473
+ },
474
+ fixable: "code",
475
+ schema: [],
476
+ messages: {
477
+ missingCause: "Re-throwing inside `catch` without `{ cause: {{binding}} }` drops the original error. Pass `{ cause: {{binding}} }` to `new {{ctor}}(...)` so the chain is preserved."
478
+ }
479
+ },
480
+ defaultOptions: [],
481
+ create(context) {
482
+ const catchBindings = [];
483
+ return {
484
+ CatchClause(node) {
485
+ const param = node.param;
486
+ catchBindings.push(
487
+ param && param.type === AST_NODE_TYPES5.Identifier ? param.name : null
488
+ );
489
+ },
490
+ "CatchClause:exit"() {
491
+ catchBindings.pop();
492
+ },
493
+ ThrowStatement(node) {
494
+ const binding = catchBindings[catchBindings.length - 1];
495
+ if (binding == null) {
496
+ return;
497
+ }
498
+ const arg = node.argument;
499
+ if (arg.type !== AST_NODE_TYPES5.NewExpression) {
500
+ return;
501
+ }
502
+ const ctor = constructorSimpleName(arg);
503
+ if (ctor === null || !isErrorLikeName(ctor)) {
504
+ return;
505
+ }
506
+ if (alreadyHasCause(arg)) {
507
+ return;
508
+ }
509
+ const fix = buildFix(arg, binding);
510
+ context.report({
511
+ node: arg,
512
+ messageId: "missingCause",
513
+ data: { binding, ctor },
514
+ ...fix ? { fix } : {}
515
+ });
516
+ }
517
+ };
518
+ }
519
+ });
520
+
521
+ // src/rules/require-registered-keys.ts
522
+ import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
523
+ var RULE_NAME6 = "require-registered-keys";
524
+ var optionSchema5 = {
525
+ type: "object",
526
+ additionalProperties: false,
527
+ properties: {
528
+ sinks: {
529
+ type: "array",
530
+ items: {
531
+ type: "object",
532
+ additionalProperties: false,
533
+ required: ["callee", "argIndex"],
534
+ properties: {
535
+ callee: { type: "string", minLength: 1 },
536
+ argIndex: { type: "integer", minimum: 0 }
537
+ }
538
+ }
539
+ },
540
+ registry: { type: "string", minLength: 1 }
541
+ }
542
+ };
543
+ function calleePath(callee) {
544
+ if (callee.type === AST_NODE_TYPES6.Identifier) {
545
+ return callee.name;
546
+ }
547
+ if (callee.type === AST_NODE_TYPES6.MemberExpression && !callee.computed) {
548
+ if (callee.property.type !== AST_NODE_TYPES6.Identifier) {
549
+ return null;
550
+ }
551
+ const objectPath = calleePath(callee.object);
552
+ return objectPath === null ? null : `${objectPath}.${callee.property.name}`;
553
+ }
554
+ return null;
555
+ }
556
+ function isStringLiteral(node) {
557
+ return node.type === AST_NODE_TYPES6.Literal && typeof node.value === "string";
558
+ }
559
+ var requireRegisteredKeysRule = createRule({
560
+ name: RULE_NAME6,
561
+ meta: {
562
+ type: "suggestion",
563
+ docs: {
564
+ description: "Require the key/name argument of configured sink APIs (storage, event channels, cache keys) to be an imported constant from a registry module, not a raw string literal."
565
+ },
566
+ schema: [optionSchema5],
567
+ messages: {
568
+ unregisteredKey: "Pass an imported key constant to `{{callee}}`, not the raw string {{value}}{{registryHint}}. Raw string keys drift out of sync across call sites."
569
+ }
570
+ },
571
+ defaultOptions: [{ sinks: [] }],
572
+ create(context, [options]) {
573
+ const sinks = options.sinks ?? [];
574
+ if (sinks.length === 0) {
575
+ return {};
576
+ }
577
+ const sinkMap = /* @__PURE__ */ new Map();
578
+ for (const sink of sinks) {
579
+ const existing = sinkMap.get(sink.callee);
580
+ if (existing) {
581
+ existing.add(sink.argIndex);
582
+ } else {
583
+ sinkMap.set(sink.callee, /* @__PURE__ */ new Set([sink.argIndex]));
584
+ }
585
+ }
586
+ const registry = options.registry;
587
+ const registryHint = registry ? ` (import it from '${registry}')` : "";
588
+ return {
589
+ CallExpression(node) {
590
+ const path2 = calleePath(node.callee);
591
+ if (path2 === null) {
592
+ return;
593
+ }
594
+ const indexes = sinkMap.get(path2);
595
+ if (indexes === void 0) {
596
+ return;
597
+ }
598
+ for (const index of indexes) {
599
+ const arg = node.arguments[index];
600
+ if (arg !== void 0 && isStringLiteral(arg)) {
601
+ context.report({
602
+ node: arg,
603
+ messageId: "unregisteredKey",
604
+ data: { callee: path2, value: `'${arg.value}'`, registryHint }
605
+ });
606
+ }
607
+ }
608
+ }
609
+ };
610
+ }
611
+ });
612
+
613
+ // src/rules/require-schema-parse-at-boundary.ts
614
+ import { AST_NODE_TYPES as AST_NODE_TYPES7 } from "@typescript-eslint/utils";
615
+ var RULE_NAME7 = "require-schema-parse-at-boundary";
616
+ function isJsonParseCall(node) {
617
+ return node.type === AST_NODE_TYPES7.CallExpression && node.callee.type === AST_NODE_TYPES7.MemberExpression && !node.callee.computed && node.callee.object.type === AST_NODE_TYPES7.Identifier && node.callee.object.name === "JSON" && node.callee.property.type === AST_NODE_TYPES7.Identifier && node.callee.property.name === "parse";
618
+ }
619
+ function isAwaitJsonCall(node) {
620
+ if (node.type !== AST_NODE_TYPES7.AwaitExpression) {
621
+ return false;
622
+ }
623
+ const call = node.argument;
624
+ return call.type === AST_NODE_TYPES7.CallExpression && call.arguments.length === 0 && call.callee.type === AST_NODE_TYPES7.MemberExpression && !call.callee.computed && call.callee.property.type === AST_NODE_TYPES7.Identifier && call.callee.property.name === "json";
625
+ }
626
+ function isShapeClaim(annotation) {
627
+ if (annotation.type === AST_NODE_TYPES7.TSArrayType) {
628
+ return true;
629
+ }
630
+ if (annotation.type === AST_NODE_TYPES7.TSTypeReference) {
631
+ return !(annotation.typeName.type === AST_NODE_TYPES7.Identifier && annotation.typeName.name === "const");
632
+ }
633
+ return false;
634
+ }
635
+ var requireSchemaParseAtBoundaryRule = createRule({
636
+ name: RULE_NAME7,
637
+ meta: {
638
+ type: "problem",
639
+ docs: {
640
+ description: "Disallow asserting external boundary data with `as T` instead of parsing it at runtime. Flags `JSON.parse(...) as T` and `(await res.json()) as T`; use a zod/valibot parse."
641
+ },
642
+ schema: [],
643
+ messages: {
644
+ castedBoundaryData: "Boundary data is asserted with `as` here, not parsed. A cast is unchecked \u2014 validate this with a runtime schema (e.g. `Schema.parse(...)`) so a wire-shape change fails loudly."
645
+ }
646
+ },
647
+ defaultOptions: [],
648
+ create(context) {
649
+ return {
650
+ TSAsExpression(node) {
651
+ if (!isShapeClaim(node.typeAnnotation)) {
652
+ return;
653
+ }
654
+ const expr = node.expression;
655
+ if (isJsonParseCall(expr) || isAwaitJsonCall(expr)) {
656
+ context.report({ node, messageId: "castedBoundaryData" });
657
+ }
658
+ }
659
+ };
660
+ }
661
+ });
662
+
663
+ // src/rules/restrict-throw-to-taxonomy.ts
664
+ import { AST_NODE_TYPES as AST_NODE_TYPES8 } from "@typescript-eslint/utils";
665
+ var RULE_NAME8 = "restrict-throw-to-taxonomy";
666
+ var DEFAULT_ALLOW = ["Error"];
667
+ var optionSchema6 = {
668
+ type: "object",
669
+ additionalProperties: false,
670
+ properties: {
671
+ allow: {
672
+ type: "array",
673
+ items: { type: "string" },
674
+ uniqueItems: true
675
+ }
676
+ }
677
+ };
678
+ function constructorSimpleName2(node) {
679
+ const callee = node.callee;
680
+ if (callee.type === AST_NODE_TYPES8.Identifier) {
681
+ return callee.name;
682
+ }
683
+ if (callee.type === AST_NODE_TYPES8.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES8.Identifier) {
684
+ return callee.property.name;
685
+ }
686
+ return null;
687
+ }
688
+ function isNonErrorValue(node) {
689
+ return node.type === AST_NODE_TYPES8.Literal || node.type === AST_NODE_TYPES8.TemplateLiteral || node.type === AST_NODE_TYPES8.ObjectExpression || node.type === AST_NODE_TYPES8.ArrayExpression;
690
+ }
691
+ var restrictThrowToTaxonomyRule = createRule({
692
+ name: RULE_NAME8,
693
+ meta: {
694
+ type: "problem",
695
+ docs: {
696
+ description: "Restrict `throw` to an approved error taxonomy. Flags throwing a non-allowlisted error class and throwing a non-Error value (string, object, number, ...)."
697
+ },
698
+ schema: [optionSchema6],
699
+ messages: {
700
+ disallowedErrorClass: "Throw an error from your taxonomy, not `{{name}}`. Allowed: {{allowed}}. Add `{{name}}` to the `allow` option if it belongs to your taxonomy.",
701
+ nonErrorThrow: "Throw an Error from your taxonomy, not a bare {{kind}} value. A non-Error throw carries no stack or cause."
702
+ }
703
+ },
704
+ defaultOptions: [{ allow: [...DEFAULT_ALLOW] }],
705
+ create(context, [options]) {
706
+ const allow = new Set(options.allow ?? DEFAULT_ALLOW);
707
+ const allowedList = [...allow].join(", ");
708
+ return {
709
+ ThrowStatement(node) {
710
+ const arg = node.argument;
711
+ if (arg.type === AST_NODE_TYPES8.NewExpression) {
712
+ const name = constructorSimpleName2(arg);
713
+ if (name !== null && !allow.has(name)) {
714
+ context.report({
715
+ node: arg,
716
+ messageId: "disallowedErrorClass",
717
+ data: { name, allowed: allowedList }
718
+ });
719
+ }
720
+ return;
721
+ }
722
+ if (isNonErrorValue(arg)) {
723
+ const kind = arg.type === AST_NODE_TYPES8.ObjectExpression ? "object" : arg.type === AST_NODE_TYPES8.ArrayExpression ? "array" : "literal";
724
+ context.report({ node: arg, messageId: "nonErrorThrow", data: { kind } });
725
+ }
726
+ }
727
+ };
728
+ }
729
+ });
730
+
309
731
  // src/rules/wire-message-naming.ts
310
- var RULE_NAME4 = "wire-message-naming";
732
+ var RULE_NAME9 = "wire-message-naming";
311
733
  var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
312
- var optionSchema4 = {
734
+ var optionSchema7 = {
313
735
  type: "object",
314
736
  additionalProperties: false,
315
737
  properties: {
@@ -350,14 +772,14 @@ function typeLiteralNode(obj) {
350
772
  return null;
351
773
  }
352
774
  var wireMessageNamingRule = createRule({
353
- name: RULE_NAME4,
775
+ name: RULE_NAME9,
354
776
  meta: {
355
777
  type: "problem",
356
778
  docs: {
357
779
  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
780
  },
359
781
  fixable: "code",
360
- schema: [optionSchema4],
782
+ schema: [optionSchema7],
361
783
  messages: {
362
784
  typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
363
785
  }
@@ -393,11 +815,11 @@ var wireMessageNamingRule = createRule({
393
815
  });
394
816
 
395
817
  // src/rules/zod-schema-naming.ts
396
- var RULE_NAME5 = "zod-schema-naming";
818
+ var RULE_NAME10 = "zod-schema-naming";
397
819
  var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
398
820
  var SUFFIX = "Schema";
399
821
  var DEFAULT_ROLE_SUFFIXES2 = [];
400
- var optionSchema5 = {
822
+ var optionSchema8 = {
401
823
  type: "object",
402
824
  additionalProperties: false,
403
825
  properties: {
@@ -430,13 +852,13 @@ function rootIdentifierName(node) {
430
852
  return null;
431
853
  }
432
854
  var zodSchemaNamingRule = createRule({
433
- name: RULE_NAME5,
855
+ name: RULE_NAME10,
434
856
  meta: {
435
857
  type: "problem",
436
858
  docs: {
437
859
  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
860
  },
439
- schema: [optionSchema5],
861
+ schema: [optionSchema8],
440
862
  messages: {
441
863
  schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
442
864
  missingType: "Schema `{{name}}` has no sibling `export type {{base}} = z.infer<typeof {{name}}>`. Export the inferred type instead of hand-authoring a duplicate."
@@ -492,12 +914,17 @@ var rules = {
492
914
  "wire-message-naming": wireMessageNamingRule,
493
915
  "no-error-stringify": noErrorStringifyRule,
494
916
  "no-direct-process-env": noDirectProcessEnvRule,
495
- "money-must-be-decimal": moneyMustBeDecimalRule
917
+ "money-must-be-decimal": moneyMustBeDecimalRule,
918
+ "require-error-cause": requireErrorCauseRule,
919
+ "restrict-throw-to-taxonomy": restrictThrowToTaxonomyRule,
920
+ "require-registered-keys": requireRegisteredKeysRule,
921
+ "env-var-schema-parity": envVarSchemaParityRule,
922
+ "require-schema-parse-at-boundary": requireSchemaParseAtBoundaryRule
496
923
  };
497
924
 
498
925
  // src/index.ts
499
926
  var NAMESPACE = "noctcore-contracts";
500
- var VERSION = "0.1.0";
927
+ var VERSION = "0.2.0";
501
928
  var plugin = {
502
929
  meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
503
930
  rules,