@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.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
@@ -32,18 +42,118 @@ var recommended = {
|
|
|
32
42
|
"noctcore-contracts/wire-message-naming": "error",
|
|
33
43
|
"noctcore-contracts/no-error-stringify": "error",
|
|
34
44
|
"noctcore-contracts/no-direct-process-env": "error",
|
|
35
|
-
"noctcore-contracts/money-must-be-decimal": "error"
|
|
45
|
+
"noctcore-contracts/money-must-be-decimal": "error",
|
|
46
|
+
"noctcore-contracts/require-error-cause": "error",
|
|
47
|
+
"noctcore-contracts/restrict-throw-to-taxonomy": "error",
|
|
48
|
+
// Config-required / heuristic rules ship inert. `require-registered-keys` and
|
|
49
|
+
// `env-var-schema-parity` do nothing until their `sinks` / `schema` options are
|
|
50
|
+
// set; `require-schema-parse-at-boundary` is a conservative syntactic slice of a
|
|
51
|
+
// type-aware concern. Enable them explicitly once configured for your project.
|
|
52
|
+
"noctcore-contracts/require-registered-keys": "off",
|
|
53
|
+
"noctcore-contracts/env-var-schema-parity": "off",
|
|
54
|
+
"noctcore-contracts/require-schema-parse-at-boundary": "off"
|
|
36
55
|
};
|
|
37
56
|
|
|
38
|
-
// src/rules/
|
|
57
|
+
// src/rules/env-var-schema-parity.ts
|
|
58
|
+
var import_node_fs = require("fs");
|
|
59
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
39
60
|
var import_utils = require("@typescript-eslint/utils");
|
|
40
61
|
|
|
41
62
|
// src/createRule.ts
|
|
42
63
|
var import_eslint_utils = require("@noctcore/eslint-utils");
|
|
43
64
|
var createRule = (0, import_eslint_utils.makeCreateRule)("contracts");
|
|
44
65
|
|
|
66
|
+
// src/rules/env-var-schema-parity.ts
|
|
67
|
+
var RULE_NAME = "env-var-schema-parity";
|
|
68
|
+
var optionSchema = {
|
|
69
|
+
type: "object",
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
properties: {
|
|
72
|
+
schema: { type: "string", minLength: 1 }
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var schemaCache = /* @__PURE__ */ new Map();
|
|
76
|
+
var DOTENV_KEY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm;
|
|
77
|
+
var OBJECT_KEY = /["'`]?([A-Za-z_][A-Za-z0-9_]*)["'`]?\s*:/g;
|
|
78
|
+
function parseDeclaredKeys(source) {
|
|
79
|
+
const keys = /* @__PURE__ */ new Set();
|
|
80
|
+
for (const match of source.matchAll(DOTENV_KEY)) {
|
|
81
|
+
if (match[1]) keys.add(match[1]);
|
|
82
|
+
}
|
|
83
|
+
for (const match of source.matchAll(OBJECT_KEY)) {
|
|
84
|
+
if (match[1]) keys.add(match[1]);
|
|
85
|
+
}
|
|
86
|
+
return keys;
|
|
87
|
+
}
|
|
88
|
+
function loadSchema(cwd, schema) {
|
|
89
|
+
const resolved = import_node_path.default.isAbsolute(schema) ? schema : import_node_path.default.resolve(cwd, schema);
|
|
90
|
+
const cached = schemaCache.get(resolved);
|
|
91
|
+
if (cached !== void 0) {
|
|
92
|
+
return cached;
|
|
93
|
+
}
|
|
94
|
+
let keys;
|
|
95
|
+
try {
|
|
96
|
+
keys = parseDeclaredKeys((0, import_node_fs.readFileSync)(resolved, "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
keys = null;
|
|
99
|
+
}
|
|
100
|
+
schemaCache.set(resolved, keys);
|
|
101
|
+
return keys;
|
|
102
|
+
}
|
|
103
|
+
function isProcessEnv(node) {
|
|
104
|
+
return node.type === import_utils.AST_NODE_TYPES.MemberExpression && !node.computed && node.object.type === import_utils.AST_NODE_TYPES.Identifier && node.object.name === "process" && node.property.type === import_utils.AST_NODE_TYPES.Identifier && node.property.name === "env";
|
|
105
|
+
}
|
|
106
|
+
function isImportMetaEnv(node) {
|
|
107
|
+
return node.type === import_utils.AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === import_utils.AST_NODE_TYPES.Identifier && node.property.name === "env" && node.object.type === import_utils.AST_NODE_TYPES.MetaProperty && node.object.meta.name === "import" && node.object.property.name === "meta";
|
|
108
|
+
}
|
|
109
|
+
var envVarSchemaParityRule = createRule({
|
|
110
|
+
name: RULE_NAME,
|
|
111
|
+
meta: {
|
|
112
|
+
type: "suggestion",
|
|
113
|
+
docs: {
|
|
114
|
+
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."
|
|
115
|
+
},
|
|
116
|
+
schema: [optionSchema],
|
|
117
|
+
messages: {
|
|
118
|
+
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."
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
defaultOptions: [{}],
|
|
122
|
+
create(context, [options]) {
|
|
123
|
+
const schema = options.schema;
|
|
124
|
+
if (!schema) {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
const declared = loadSchema(context.cwd, schema);
|
|
128
|
+
if (declared === null) {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
function check(node) {
|
|
132
|
+
if (node.computed || node.property.type !== import_utils.AST_NODE_TYPES.Identifier) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const name = node.property.name;
|
|
136
|
+
if (!declared.has(name)) {
|
|
137
|
+
context.report({
|
|
138
|
+
node: node.property,
|
|
139
|
+
messageId: "undeclaredEnvVar",
|
|
140
|
+
data: { name, schema }
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
MemberExpression(node) {
|
|
146
|
+
if (isProcessEnv(node.object) || isImportMetaEnv(node.object)) {
|
|
147
|
+
check(node);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
45
154
|
// src/rules/money-must-be-decimal.ts
|
|
46
|
-
var
|
|
155
|
+
var import_utils2 = require("@typescript-eslint/utils");
|
|
156
|
+
var RULE_NAME2 = "money-must-be-decimal";
|
|
47
157
|
var DEFAULT_DECIMAL_TYPE = "Decimal";
|
|
48
158
|
var DEFAULT_FIELD_PATTERNS = [
|
|
49
159
|
"amount",
|
|
@@ -53,7 +163,7 @@ var DEFAULT_FIELD_PATTERNS = [
|
|
|
53
163
|
"balance"
|
|
54
164
|
];
|
|
55
165
|
var DEFAULT_ALLOWED_FILES = [];
|
|
56
|
-
var
|
|
166
|
+
var optionSchema2 = {
|
|
57
167
|
type: "object",
|
|
58
168
|
additionalProperties: false,
|
|
59
169
|
properties: {
|
|
@@ -82,22 +192,22 @@ function isAllowedFile(filename, patterns) {
|
|
|
82
192
|
return patterns.some((pattern) => normalized.endsWith(toForwardSlash(pattern)));
|
|
83
193
|
}
|
|
84
194
|
function staticName(node) {
|
|
85
|
-
if (node.type ===
|
|
195
|
+
if (node.type === import_utils2.AST_NODE_TYPES.Identifier) {
|
|
86
196
|
return node.name;
|
|
87
197
|
}
|
|
88
198
|
return void 0;
|
|
89
199
|
}
|
|
90
200
|
function isNumberAnnotation(annotation) {
|
|
91
|
-
return annotation?.typeAnnotation.type ===
|
|
201
|
+
return annotation?.typeAnnotation.type === import_utils2.AST_NODE_TYPES.TSNumberKeyword;
|
|
92
202
|
}
|
|
93
203
|
var moneyMustBeDecimalRule = createRule({
|
|
94
|
-
name:
|
|
204
|
+
name: RULE_NAME2,
|
|
95
205
|
meta: {
|
|
96
206
|
type: "problem",
|
|
97
207
|
docs: {
|
|
98
208
|
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
209
|
},
|
|
100
|
-
schema: [
|
|
210
|
+
schema: [optionSchema2],
|
|
101
211
|
messages: {
|
|
102
212
|
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
213
|
}
|
|
@@ -133,7 +243,7 @@ var moneyMustBeDecimalRule = createRule({
|
|
|
133
243
|
},
|
|
134
244
|
// `const total: number = ...`: annotated variable declarator.
|
|
135
245
|
VariableDeclarator(node) {
|
|
136
|
-
if (node.id.type !==
|
|
246
|
+
if (node.id.type !== import_utils2.AST_NODE_TYPES.Identifier) {
|
|
137
247
|
return;
|
|
138
248
|
}
|
|
139
249
|
const name = node.id.name;
|
|
@@ -146,15 +256,15 @@ var moneyMustBeDecimalRule = createRule({
|
|
|
146
256
|
});
|
|
147
257
|
|
|
148
258
|
// src/rules/no-direct-process-env.ts
|
|
149
|
-
var
|
|
150
|
-
var
|
|
259
|
+
var import_utils3 = require("@typescript-eslint/utils");
|
|
260
|
+
var RULE_NAME3 = "no-direct-process-env";
|
|
151
261
|
var DEFAULT_CONFIG_MODULE = "@/config";
|
|
152
262
|
var DEFAULT_ALLOWED_FILES2 = [
|
|
153
263
|
"**/*.config.{ts,js,mjs,cjs}",
|
|
154
264
|
"**/*.{spec,test}.{ts,tsx}",
|
|
155
265
|
"**/scripts/**"
|
|
156
266
|
];
|
|
157
|
-
var
|
|
267
|
+
var optionSchema3 = {
|
|
158
268
|
type: "object",
|
|
159
269
|
additionalProperties: false,
|
|
160
270
|
properties: {
|
|
@@ -208,23 +318,23 @@ function isAllowedFile2(filename, patterns) {
|
|
|
208
318
|
const normalized = filename.split("\\").join("/");
|
|
209
319
|
return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
210
320
|
}
|
|
211
|
-
function
|
|
212
|
-
if (node.type !==
|
|
321
|
+
function isProcessEnv2(node) {
|
|
322
|
+
if (node.type !== import_utils3.AST_NODE_TYPES.MemberExpression || node.object.type !== import_utils3.AST_NODE_TYPES.Identifier || node.object.name !== "process") {
|
|
213
323
|
return false;
|
|
214
324
|
}
|
|
215
325
|
if (node.computed) {
|
|
216
|
-
return node.property.type ===
|
|
326
|
+
return node.property.type === import_utils3.AST_NODE_TYPES.Literal && node.property.value === "env";
|
|
217
327
|
}
|
|
218
|
-
return node.property.type ===
|
|
328
|
+
return node.property.type === import_utils3.AST_NODE_TYPES.Identifier && node.property.name === "env";
|
|
219
329
|
}
|
|
220
330
|
var noDirectProcessEnvRule = createRule({
|
|
221
|
-
name:
|
|
331
|
+
name: RULE_NAME3,
|
|
222
332
|
meta: {
|
|
223
333
|
type: "problem",
|
|
224
334
|
docs: {
|
|
225
335
|
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
336
|
},
|
|
227
|
-
schema: [
|
|
337
|
+
schema: [optionSchema3],
|
|
228
338
|
messages: {
|
|
229
339
|
directProcessEnv: "Read environment variables through your typed config accessor (import from `{{configModule}}`). Direct `process.env` access bypasses boot-time validation."
|
|
230
340
|
}
|
|
@@ -250,7 +360,7 @@ var noDirectProcessEnvRule = createRule({
|
|
|
250
360
|
* returned, or assigned (`log(process.env)`, `return process.env`).
|
|
251
361
|
*/
|
|
252
362
|
MemberExpression(node) {
|
|
253
|
-
if (
|
|
363
|
+
if (isProcessEnv2(node)) {
|
|
254
364
|
context.report({ node, messageId: "directProcessEnv", data: { configModule } });
|
|
255
365
|
}
|
|
256
366
|
}
|
|
@@ -259,10 +369,10 @@ var noDirectProcessEnvRule = createRule({
|
|
|
259
369
|
});
|
|
260
370
|
|
|
261
371
|
// src/rules/no-error-stringify.ts
|
|
262
|
-
var
|
|
263
|
-
var
|
|
372
|
+
var import_utils4 = require("@typescript-eslint/utils");
|
|
373
|
+
var RULE_NAME4 = "no-error-stringify";
|
|
264
374
|
var DEFAULT_ERROR_NAMES = ["error", "err", "e", "cause"];
|
|
265
|
-
var
|
|
375
|
+
var optionSchema4 = {
|
|
266
376
|
type: "object",
|
|
267
377
|
additionalProperties: false,
|
|
268
378
|
properties: {
|
|
@@ -275,19 +385,19 @@ var optionSchema3 = {
|
|
|
275
385
|
}
|
|
276
386
|
};
|
|
277
387
|
function isEmptyStringLiteral(node) {
|
|
278
|
-
return node.type ===
|
|
388
|
+
return node.type === import_utils4.AST_NODE_TYPES.Literal && node.value === "";
|
|
279
389
|
}
|
|
280
390
|
function isErrorIdentifier(node, names) {
|
|
281
|
-
return node.type ===
|
|
391
|
+
return node.type === import_utils4.AST_NODE_TYPES.Identifier && names.has(node.name);
|
|
282
392
|
}
|
|
283
393
|
var noErrorStringifyRule = createRule({
|
|
284
|
-
name:
|
|
394
|
+
name: RULE_NAME4,
|
|
285
395
|
meta: {
|
|
286
396
|
type: "problem",
|
|
287
397
|
docs: {
|
|
288
398
|
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
399
|
},
|
|
290
|
-
schema: [
|
|
400
|
+
schema: [optionSchema4],
|
|
291
401
|
messages: {
|
|
292
402
|
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
403
|
}
|
|
@@ -302,7 +412,7 @@ var noErrorStringifyRule = createRule({
|
|
|
302
412
|
// `error.toString()`
|
|
303
413
|
'CallExpression[callee.type="MemberExpression"]'(node) {
|
|
304
414
|
const callee = node.callee;
|
|
305
|
-
if (!callee.computed && callee.property.type ===
|
|
415
|
+
if (!callee.computed && callee.property.type === import_utils4.AST_NODE_TYPES.Identifier && callee.property.name === "toString" && node.arguments.length === 0 && isErrorIdentifier(callee.object, errorNames)) {
|
|
306
416
|
report(node, callee.object.name);
|
|
307
417
|
}
|
|
308
418
|
},
|
|
@@ -334,10 +444,332 @@ var noErrorStringifyRule = createRule({
|
|
|
334
444
|
}
|
|
335
445
|
});
|
|
336
446
|
|
|
447
|
+
// src/rules/require-error-cause.ts
|
|
448
|
+
var import_utils5 = require("@typescript-eslint/utils");
|
|
449
|
+
var RULE_NAME5 = "require-error-cause";
|
|
450
|
+
function constructorSimpleName(node) {
|
|
451
|
+
const callee = node.callee;
|
|
452
|
+
if (callee.type === import_utils5.AST_NODE_TYPES.Identifier) {
|
|
453
|
+
return callee.name;
|
|
454
|
+
}
|
|
455
|
+
if (callee.type === import_utils5.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === import_utils5.AST_NODE_TYPES.Identifier) {
|
|
456
|
+
return callee.property.name;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
function isErrorLikeName(name) {
|
|
461
|
+
return /(?:Error|Exception)$/.test(name);
|
|
462
|
+
}
|
|
463
|
+
function alreadyHasCause(node) {
|
|
464
|
+
for (const arg of node.arguments) {
|
|
465
|
+
if (arg.type === import_utils5.AST_NODE_TYPES.SpreadElement) {
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
if (arg.type === import_utils5.AST_NODE_TYPES.ObjectExpression) {
|
|
469
|
+
for (const prop of arg.properties) {
|
|
470
|
+
if (prop.type === import_utils5.AST_NODE_TYPES.SpreadElement) {
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
const key = prop.key;
|
|
474
|
+
const isCause = key.type === import_utils5.AST_NODE_TYPES.Identifier && key.name === "cause" || key.type === import_utils5.AST_NODE_TYPES.Literal && key.value === "cause";
|
|
475
|
+
if (isCause) {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
function buildFix(node, binding) {
|
|
484
|
+
const args = node.arguments;
|
|
485
|
+
if (args.length === 0) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const last = args[args.length - 1];
|
|
489
|
+
if (last === void 0) {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
if (last.type === import_utils5.AST_NODE_TYPES.ObjectExpression) {
|
|
493
|
+
const props = last.properties;
|
|
494
|
+
if (props.length === 0) {
|
|
495
|
+
return (fixer) => fixer.replaceText(last, `{ cause: ${binding} }`);
|
|
496
|
+
}
|
|
497
|
+
const lastProp = props[props.length - 1];
|
|
498
|
+
if (lastProp === void 0) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
return (fixer) => fixer.insertTextAfter(lastProp, `, cause: ${binding}`);
|
|
502
|
+
}
|
|
503
|
+
return (fixer) => fixer.insertTextAfter(last, `, { cause: ${binding} }`);
|
|
504
|
+
}
|
|
505
|
+
var requireErrorCauseRule = createRule({
|
|
506
|
+
name: RULE_NAME5,
|
|
507
|
+
meta: {
|
|
508
|
+
type: "problem",
|
|
509
|
+
docs: {
|
|
510
|
+
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."
|
|
511
|
+
},
|
|
512
|
+
fixable: "code",
|
|
513
|
+
schema: [],
|
|
514
|
+
messages: {
|
|
515
|
+
missingCause: "Re-throwing inside `catch` without `{ cause: {{binding}} }` drops the original error. Pass `{ cause: {{binding}} }` to `new {{ctor}}(...)` so the chain is preserved."
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
defaultOptions: [],
|
|
519
|
+
create(context) {
|
|
520
|
+
const catchBindings = [];
|
|
521
|
+
return {
|
|
522
|
+
CatchClause(node) {
|
|
523
|
+
const param = node.param;
|
|
524
|
+
catchBindings.push(
|
|
525
|
+
param && param.type === import_utils5.AST_NODE_TYPES.Identifier ? param.name : null
|
|
526
|
+
);
|
|
527
|
+
},
|
|
528
|
+
"CatchClause:exit"() {
|
|
529
|
+
catchBindings.pop();
|
|
530
|
+
},
|
|
531
|
+
ThrowStatement(node) {
|
|
532
|
+
const binding = catchBindings[catchBindings.length - 1];
|
|
533
|
+
if (binding == null) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const arg = node.argument;
|
|
537
|
+
if (arg.type !== import_utils5.AST_NODE_TYPES.NewExpression) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const ctor = constructorSimpleName(arg);
|
|
541
|
+
if (ctor === null || !isErrorLikeName(ctor)) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (alreadyHasCause(arg)) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const fix = buildFix(arg, binding);
|
|
548
|
+
context.report({
|
|
549
|
+
node: arg,
|
|
550
|
+
messageId: "missingCause",
|
|
551
|
+
data: { binding, ctor },
|
|
552
|
+
...fix ? { fix } : {}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// src/rules/require-registered-keys.ts
|
|
560
|
+
var import_utils6 = require("@typescript-eslint/utils");
|
|
561
|
+
var RULE_NAME6 = "require-registered-keys";
|
|
562
|
+
var optionSchema5 = {
|
|
563
|
+
type: "object",
|
|
564
|
+
additionalProperties: false,
|
|
565
|
+
properties: {
|
|
566
|
+
sinks: {
|
|
567
|
+
type: "array",
|
|
568
|
+
items: {
|
|
569
|
+
type: "object",
|
|
570
|
+
additionalProperties: false,
|
|
571
|
+
required: ["callee", "argIndex"],
|
|
572
|
+
properties: {
|
|
573
|
+
callee: { type: "string", minLength: 1 },
|
|
574
|
+
argIndex: { type: "integer", minimum: 0 }
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
registry: { type: "string", minLength: 1 }
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
function calleePath(callee) {
|
|
582
|
+
if (callee.type === import_utils6.AST_NODE_TYPES.Identifier) {
|
|
583
|
+
return callee.name;
|
|
584
|
+
}
|
|
585
|
+
if (callee.type === import_utils6.AST_NODE_TYPES.MemberExpression && !callee.computed) {
|
|
586
|
+
if (callee.property.type !== import_utils6.AST_NODE_TYPES.Identifier) {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const objectPath = calleePath(callee.object);
|
|
590
|
+
return objectPath === null ? null : `${objectPath}.${callee.property.name}`;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
function isStringLiteral(node) {
|
|
595
|
+
return node.type === import_utils6.AST_NODE_TYPES.Literal && typeof node.value === "string";
|
|
596
|
+
}
|
|
597
|
+
var requireRegisteredKeysRule = createRule({
|
|
598
|
+
name: RULE_NAME6,
|
|
599
|
+
meta: {
|
|
600
|
+
type: "suggestion",
|
|
601
|
+
docs: {
|
|
602
|
+
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."
|
|
603
|
+
},
|
|
604
|
+
schema: [optionSchema5],
|
|
605
|
+
messages: {
|
|
606
|
+
unregisteredKey: "Pass an imported key constant to `{{callee}}`, not the raw string {{value}}{{registryHint}}. Raw string keys drift out of sync across call sites."
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
defaultOptions: [{ sinks: [] }],
|
|
610
|
+
create(context, [options]) {
|
|
611
|
+
const sinks = options.sinks ?? [];
|
|
612
|
+
if (sinks.length === 0) {
|
|
613
|
+
return {};
|
|
614
|
+
}
|
|
615
|
+
const sinkMap = /* @__PURE__ */ new Map();
|
|
616
|
+
for (const sink of sinks) {
|
|
617
|
+
const existing = sinkMap.get(sink.callee);
|
|
618
|
+
if (existing) {
|
|
619
|
+
existing.add(sink.argIndex);
|
|
620
|
+
} else {
|
|
621
|
+
sinkMap.set(sink.callee, /* @__PURE__ */ new Set([sink.argIndex]));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const registry = options.registry;
|
|
625
|
+
const registryHint = registry ? ` (import it from '${registry}')` : "";
|
|
626
|
+
return {
|
|
627
|
+
CallExpression(node) {
|
|
628
|
+
const path2 = calleePath(node.callee);
|
|
629
|
+
if (path2 === null) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const indexes = sinkMap.get(path2);
|
|
633
|
+
if (indexes === void 0) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
for (const index of indexes) {
|
|
637
|
+
const arg = node.arguments[index];
|
|
638
|
+
if (arg !== void 0 && isStringLiteral(arg)) {
|
|
639
|
+
context.report({
|
|
640
|
+
node: arg,
|
|
641
|
+
messageId: "unregisteredKey",
|
|
642
|
+
data: { callee: path2, value: `'${arg.value}'`, registryHint }
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// src/rules/require-schema-parse-at-boundary.ts
|
|
652
|
+
var import_utils7 = require("@typescript-eslint/utils");
|
|
653
|
+
var RULE_NAME7 = "require-schema-parse-at-boundary";
|
|
654
|
+
function isJsonParseCall(node) {
|
|
655
|
+
return node.type === import_utils7.AST_NODE_TYPES.CallExpression && node.callee.type === import_utils7.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === import_utils7.AST_NODE_TYPES.Identifier && node.callee.object.name === "JSON" && node.callee.property.type === import_utils7.AST_NODE_TYPES.Identifier && node.callee.property.name === "parse";
|
|
656
|
+
}
|
|
657
|
+
function isAwaitJsonCall(node) {
|
|
658
|
+
if (node.type !== import_utils7.AST_NODE_TYPES.AwaitExpression) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
const call = node.argument;
|
|
662
|
+
return call.type === import_utils7.AST_NODE_TYPES.CallExpression && call.arguments.length === 0 && call.callee.type === import_utils7.AST_NODE_TYPES.MemberExpression && !call.callee.computed && call.callee.property.type === import_utils7.AST_NODE_TYPES.Identifier && call.callee.property.name === "json";
|
|
663
|
+
}
|
|
664
|
+
function isShapeClaim(annotation) {
|
|
665
|
+
if (annotation.type === import_utils7.AST_NODE_TYPES.TSArrayType) {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
if (annotation.type === import_utils7.AST_NODE_TYPES.TSTypeReference) {
|
|
669
|
+
return !(annotation.typeName.type === import_utils7.AST_NODE_TYPES.Identifier && annotation.typeName.name === "const");
|
|
670
|
+
}
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
var requireSchemaParseAtBoundaryRule = createRule({
|
|
674
|
+
name: RULE_NAME7,
|
|
675
|
+
meta: {
|
|
676
|
+
type: "problem",
|
|
677
|
+
docs: {
|
|
678
|
+
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."
|
|
679
|
+
},
|
|
680
|
+
schema: [],
|
|
681
|
+
messages: {
|
|
682
|
+
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."
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
defaultOptions: [],
|
|
686
|
+
create(context) {
|
|
687
|
+
return {
|
|
688
|
+
TSAsExpression(node) {
|
|
689
|
+
if (!isShapeClaim(node.typeAnnotation)) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const expr = node.expression;
|
|
693
|
+
if (isJsonParseCall(expr) || isAwaitJsonCall(expr)) {
|
|
694
|
+
context.report({ node, messageId: "castedBoundaryData" });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// src/rules/restrict-throw-to-taxonomy.ts
|
|
702
|
+
var import_utils8 = require("@typescript-eslint/utils");
|
|
703
|
+
var RULE_NAME8 = "restrict-throw-to-taxonomy";
|
|
704
|
+
var DEFAULT_ALLOW = ["Error"];
|
|
705
|
+
var optionSchema6 = {
|
|
706
|
+
type: "object",
|
|
707
|
+
additionalProperties: false,
|
|
708
|
+
properties: {
|
|
709
|
+
allow: {
|
|
710
|
+
type: "array",
|
|
711
|
+
items: { type: "string" },
|
|
712
|
+
uniqueItems: true
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
function constructorSimpleName2(node) {
|
|
717
|
+
const callee = node.callee;
|
|
718
|
+
if (callee.type === import_utils8.AST_NODE_TYPES.Identifier) {
|
|
719
|
+
return callee.name;
|
|
720
|
+
}
|
|
721
|
+
if (callee.type === import_utils8.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === import_utils8.AST_NODE_TYPES.Identifier) {
|
|
722
|
+
return callee.property.name;
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
function isNonErrorValue(node) {
|
|
727
|
+
return node.type === import_utils8.AST_NODE_TYPES.Literal || node.type === import_utils8.AST_NODE_TYPES.TemplateLiteral || node.type === import_utils8.AST_NODE_TYPES.ObjectExpression || node.type === import_utils8.AST_NODE_TYPES.ArrayExpression;
|
|
728
|
+
}
|
|
729
|
+
var restrictThrowToTaxonomyRule = createRule({
|
|
730
|
+
name: RULE_NAME8,
|
|
731
|
+
meta: {
|
|
732
|
+
type: "problem",
|
|
733
|
+
docs: {
|
|
734
|
+
description: "Restrict `throw` to an approved error taxonomy. Flags throwing a non-allowlisted error class and throwing a non-Error value (string, object, number, ...)."
|
|
735
|
+
},
|
|
736
|
+
schema: [optionSchema6],
|
|
737
|
+
messages: {
|
|
738
|
+
disallowedErrorClass: "Throw an error from your taxonomy, not `{{name}}`. Allowed: {{allowed}}. Add `{{name}}` to the `allow` option if it belongs to your taxonomy.",
|
|
739
|
+
nonErrorThrow: "Throw an Error from your taxonomy, not a bare {{kind}} value. A non-Error throw carries no stack or cause."
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
defaultOptions: [{ allow: [...DEFAULT_ALLOW] }],
|
|
743
|
+
create(context, [options]) {
|
|
744
|
+
const allow = new Set(options.allow ?? DEFAULT_ALLOW);
|
|
745
|
+
const allowedList = [...allow].join(", ");
|
|
746
|
+
return {
|
|
747
|
+
ThrowStatement(node) {
|
|
748
|
+
const arg = node.argument;
|
|
749
|
+
if (arg.type === import_utils8.AST_NODE_TYPES.NewExpression) {
|
|
750
|
+
const name = constructorSimpleName2(arg);
|
|
751
|
+
if (name !== null && !allow.has(name)) {
|
|
752
|
+
context.report({
|
|
753
|
+
node: arg,
|
|
754
|
+
messageId: "disallowedErrorClass",
|
|
755
|
+
data: { name, allowed: allowedList }
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (isNonErrorValue(arg)) {
|
|
761
|
+
const kind = arg.type === import_utils8.AST_NODE_TYPES.ObjectExpression ? "object" : arg.type === import_utils8.AST_NODE_TYPES.ArrayExpression ? "array" : "literal";
|
|
762
|
+
context.report({ node: arg, messageId: "nonErrorThrow", data: { kind } });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
337
769
|
// src/rules/wire-message-naming.ts
|
|
338
|
-
var
|
|
770
|
+
var RULE_NAME9 = "wire-message-naming";
|
|
339
771
|
var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
|
|
340
|
-
var
|
|
772
|
+
var optionSchema7 = {
|
|
341
773
|
type: "object",
|
|
342
774
|
additionalProperties: false,
|
|
343
775
|
properties: {
|
|
@@ -378,14 +810,14 @@ function typeLiteralNode(obj) {
|
|
|
378
810
|
return null;
|
|
379
811
|
}
|
|
380
812
|
var wireMessageNamingRule = createRule({
|
|
381
|
-
name:
|
|
813
|
+
name: RULE_NAME9,
|
|
382
814
|
meta: {
|
|
383
815
|
type: "problem",
|
|
384
816
|
docs: {
|
|
385
817
|
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
818
|
},
|
|
387
819
|
fixable: "code",
|
|
388
|
-
schema: [
|
|
820
|
+
schema: [optionSchema7],
|
|
389
821
|
messages: {
|
|
390
822
|
typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
|
|
391
823
|
}
|
|
@@ -421,11 +853,11 @@ var wireMessageNamingRule = createRule({
|
|
|
421
853
|
});
|
|
422
854
|
|
|
423
855
|
// src/rules/zod-schema-naming.ts
|
|
424
|
-
var
|
|
856
|
+
var RULE_NAME10 = "zod-schema-naming";
|
|
425
857
|
var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
|
|
426
858
|
var SUFFIX = "Schema";
|
|
427
859
|
var DEFAULT_ROLE_SUFFIXES2 = [];
|
|
428
|
-
var
|
|
860
|
+
var optionSchema8 = {
|
|
429
861
|
type: "object",
|
|
430
862
|
additionalProperties: false,
|
|
431
863
|
properties: {
|
|
@@ -458,13 +890,13 @@ function rootIdentifierName(node) {
|
|
|
458
890
|
return null;
|
|
459
891
|
}
|
|
460
892
|
var zodSchemaNamingRule = createRule({
|
|
461
|
-
name:
|
|
893
|
+
name: RULE_NAME10,
|
|
462
894
|
meta: {
|
|
463
895
|
type: "problem",
|
|
464
896
|
docs: {
|
|
465
897
|
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
898
|
},
|
|
467
|
-
schema: [
|
|
899
|
+
schema: [optionSchema8],
|
|
468
900
|
messages: {
|
|
469
901
|
schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
|
|
470
902
|
missingType: "Schema `{{name}}` has no sibling `export type {{base}} = z.infer<typeof {{name}}>`. Export the inferred type instead of hand-authoring a duplicate."
|
|
@@ -520,12 +952,17 @@ var rules = {
|
|
|
520
952
|
"wire-message-naming": wireMessageNamingRule,
|
|
521
953
|
"no-error-stringify": noErrorStringifyRule,
|
|
522
954
|
"no-direct-process-env": noDirectProcessEnvRule,
|
|
523
|
-
"money-must-be-decimal": moneyMustBeDecimalRule
|
|
955
|
+
"money-must-be-decimal": moneyMustBeDecimalRule,
|
|
956
|
+
"require-error-cause": requireErrorCauseRule,
|
|
957
|
+
"restrict-throw-to-taxonomy": restrictThrowToTaxonomyRule,
|
|
958
|
+
"require-registered-keys": requireRegisteredKeysRule,
|
|
959
|
+
"env-var-schema-parity": envVarSchemaParityRule,
|
|
960
|
+
"require-schema-parse-at-boundary": requireSchemaParseAtBoundaryRule
|
|
524
961
|
};
|
|
525
962
|
|
|
526
963
|
// src/index.ts
|
|
527
964
|
var NAMESPACE = "noctcore-contracts";
|
|
528
|
-
var VERSION = "0.
|
|
965
|
+
var VERSION = "0.2.0";
|
|
529
966
|
var plugin = {
|
|
530
967
|
meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
|
|
531
968
|
rules,
|