@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/README.md +18 -7
- package/dist/index.cjs +474 -37
- package/dist/index.d.cts +53 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +464 -37
- package/docs/rules/env-var-schema-parity.md +54 -0
- package/docs/rules/require-error-cause.md +67 -0
- package/docs/rules/require-registered-keys.md +62 -0
- package/docs/rules/require-schema-parse-at-boundary.md +47 -0
- package/docs/rules/restrict-throw-to-taxonomy.md +54 -0
- package/package.json +1 -1
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/
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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 ===
|
|
163
|
+
return annotation?.typeAnnotation.type === AST_NODE_TYPES2.TSNumberKeyword;
|
|
64
164
|
}
|
|
65
165
|
var moneyMustBeDecimalRule = createRule({
|
|
66
|
-
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: [
|
|
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 !==
|
|
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
|
|
122
|
-
var
|
|
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
|
|
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
|
|
184
|
-
if (node.type !==
|
|
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 ===
|
|
288
|
+
return node.property.type === AST_NODE_TYPES3.Literal && node.property.value === "env";
|
|
189
289
|
}
|
|
190
|
-
return node.property.type ===
|
|
290
|
+
return node.property.type === AST_NODE_TYPES3.Identifier && node.property.name === "env";
|
|
191
291
|
}
|
|
192
292
|
var noDirectProcessEnvRule = createRule({
|
|
193
|
-
name:
|
|
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: [
|
|
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 (
|
|
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
|
|
235
|
-
var
|
|
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
|
|
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 ===
|
|
350
|
+
return node.type === AST_NODE_TYPES4.Literal && node.value === "";
|
|
251
351
|
}
|
|
252
352
|
function isErrorIdentifier(node, names) {
|
|
253
|
-
return node.type ===
|
|
353
|
+
return node.type === AST_NODE_TYPES4.Identifier && names.has(node.name);
|
|
254
354
|
}
|
|
255
355
|
var noErrorStringifyRule = createRule({
|
|
256
|
-
name:
|
|
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: [
|
|
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 ===
|
|
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
|
|
732
|
+
var RULE_NAME9 = "wire-message-naming";
|
|
311
733
|
var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
|
|
312
|
-
var
|
|
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:
|
|
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: [
|
|
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
|
|
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
|
|
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:
|
|
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: [
|
|
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.
|
|
927
|
+
var VERSION = "0.2.0";
|
|
501
928
|
var plugin = {
|
|
502
929
|
meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
|
|
503
930
|
rules,
|