@noctcore/eslint-plugin-contracts 0.6.1 → 0.7.1
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 +74 -32
- package/dist/index.cjs +330 -34
- package/dist/index.d.cts +36 -26
- package/dist/index.d.ts +36 -26
- package/dist/index.js +331 -35
- package/docs/rules/env-var-schema-parity.md +10 -2
- package/docs/rules/fetch-must-check-ok.md +18 -6
- package/docs/rules/money-must-be-decimal.md +14 -5
- package/docs/rules/no-direct-process-env.md +10 -2
- package/docs/rules/no-error-stringify.md +10 -0
- package/docs/rules/require-error-cause.md +17 -13
- package/docs/rules/require-registered-keys.md +13 -4
- package/docs/rules/require-schema-parse-at-boundary.md +101 -7
- package/docs/rules/restrict-throw-to-taxonomy.md +10 -4
- package/docs/rules/schema-enum-field-consistency.md +14 -0
- package/docs/rules/translation-key-exists.md +14 -10
- package/docs/rules/wire-message-naming.md +9 -1
- package/docs/rules/zod-schema-naming.md +10 -0
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -76,7 +76,8 @@ var envVarSchemaParityRule = createRule({
|
|
|
76
76
|
meta: {
|
|
77
77
|
type: "suggestion",
|
|
78
78
|
docs: {
|
|
79
|
-
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."
|
|
79
|
+
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.",
|
|
80
|
+
requiresOptions: true
|
|
80
81
|
},
|
|
81
82
|
schema: [optionSchema],
|
|
82
83
|
messages: {
|
|
@@ -1043,7 +1044,8 @@ var requireRegisteredKeysRule = createRule({
|
|
|
1043
1044
|
meta: {
|
|
1044
1045
|
type: "suggestion",
|
|
1045
1046
|
docs: {
|
|
1046
|
-
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."
|
|
1047
|
+
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.",
|
|
1048
|
+
requiresOptions: true
|
|
1047
1049
|
},
|
|
1048
1050
|
schema: [optionSchema6],
|
|
1049
1051
|
messages: {
|
|
@@ -1093,22 +1095,315 @@ var requireRegisteredKeysRule = createRule({
|
|
|
1093
1095
|
});
|
|
1094
1096
|
|
|
1095
1097
|
// src/rules/require-schema-parse-at-boundary.ts
|
|
1096
|
-
import { AST_NODE_TYPES as AST_NODE_TYPES8 } from "@typescript-eslint/utils";
|
|
1098
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES8, ASTUtils } from "@typescript-eslint/utils";
|
|
1097
1099
|
var RULE_NAME8 = "require-schema-parse-at-boundary";
|
|
1100
|
+
var optionSchema7 = {
|
|
1101
|
+
type: "object",
|
|
1102
|
+
additionalProperties: false,
|
|
1103
|
+
properties: {
|
|
1104
|
+
boundaries: {
|
|
1105
|
+
type: "array",
|
|
1106
|
+
items: { type: "string", minLength: 1 },
|
|
1107
|
+
uniqueItems: true
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
function parentOf(node) {
|
|
1112
|
+
return node.parent ?? null;
|
|
1113
|
+
}
|
|
1114
|
+
function memberPath(node) {
|
|
1115
|
+
if (node.type === AST_NODE_TYPES8.Identifier) {
|
|
1116
|
+
return node.name;
|
|
1117
|
+
}
|
|
1118
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES8.Identifier) {
|
|
1119
|
+
const object = memberPath(node.object);
|
|
1120
|
+
return object === null ? null : `${object}.${node.property.name}`;
|
|
1121
|
+
}
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
function methodCallReceiver(node, names) {
|
|
1125
|
+
if (node.type === AST_NODE_TYPES8.CallExpression && node.callee.type === AST_NODE_TYPES8.MemberExpression && !node.callee.computed && node.callee.property.type === AST_NODE_TYPES8.Identifier && names.includes(node.callee.property.name)) {
|
|
1126
|
+
return node.callee.object;
|
|
1127
|
+
}
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
function unwrap(node) {
|
|
1131
|
+
let current = node;
|
|
1132
|
+
while (current.type === AST_NODE_TYPES8.TSNonNullExpression || current.type === AST_NODE_TYPES8.ChainExpression) {
|
|
1133
|
+
current = current.expression;
|
|
1134
|
+
}
|
|
1135
|
+
return current;
|
|
1136
|
+
}
|
|
1098
1137
|
function isJsonParseCall(node) {
|
|
1099
|
-
return node.type === AST_NODE_TYPES8.CallExpression && node.callee
|
|
1138
|
+
return node.type === AST_NODE_TYPES8.CallExpression && memberPath(node.callee) === "JSON.parse";
|
|
1100
1139
|
}
|
|
1101
1140
|
function isAwaitJsonCall(node) {
|
|
1102
1141
|
if (node.type !== AST_NODE_TYPES8.AwaitExpression) {
|
|
1103
1142
|
return false;
|
|
1104
1143
|
}
|
|
1105
1144
|
const call = node.argument;
|
|
1106
|
-
return call
|
|
1145
|
+
return methodCallReceiver(call, ["json"]) !== null && call.type === AST_NODE_TYPES8.CallExpression && call.arguments.length === 0;
|
|
1146
|
+
}
|
|
1147
|
+
function isStorageRead(node) {
|
|
1148
|
+
const receiver = methodCallReceiver(node, ["getItem"]);
|
|
1149
|
+
const path3 = receiver === null ? null : memberPath(receiver);
|
|
1150
|
+
if (path3 === null) {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
const last = path3.slice(path3.lastIndexOf(".") + 1);
|
|
1154
|
+
return last === "localStorage" || last === "sessionStorage";
|
|
1155
|
+
}
|
|
1156
|
+
var BoundaryMatcher = class {
|
|
1157
|
+
sourceCode;
|
|
1158
|
+
boundaries;
|
|
1159
|
+
constructor(sourceCode, boundaries) {
|
|
1160
|
+
this.sourceCode = sourceCode;
|
|
1161
|
+
this.boundaries = new Set(boundaries);
|
|
1162
|
+
}
|
|
1163
|
+
/** True when `expr`, the operand of the cast at `cast`, is boundary data. */
|
|
1164
|
+
isBoundary(expr, cast, seen) {
|
|
1165
|
+
const node = unwrap(expr);
|
|
1166
|
+
if (isJsonParseCall(node) || isAwaitJsonCall(node) || isStorageRead(node) || this.isSearchParamsRead(node) || this.isConfiguredBoundary(node) || this.isMessageEventData(node) || this.isToolUseInput(node)) {
|
|
1167
|
+
return true;
|
|
1168
|
+
}
|
|
1169
|
+
if (node.type !== AST_NODE_TYPES8.Identifier) {
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
const variable = this.resolve(node);
|
|
1173
|
+
if (variable === null || seen.has(variable)) {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
seen.add(variable);
|
|
1177
|
+
const init = this.constInit(variable);
|
|
1178
|
+
return init !== null && this.untouchedBefore(variable, node, cast) && this.isBoundary(init, init, seen);
|
|
1179
|
+
}
|
|
1180
|
+
resolve(identifier) {
|
|
1181
|
+
return ASTUtils.findVariable(this.sourceCode.getScope(identifier), identifier) ?? null;
|
|
1182
|
+
}
|
|
1183
|
+
/** The initializer of a single `const name = <init>` declaration, or null. */
|
|
1184
|
+
constInit(variable) {
|
|
1185
|
+
const [def] = variable.defs;
|
|
1186
|
+
if (variable.defs.length !== 1 || def === void 0) {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
const declarator = def.node;
|
|
1190
|
+
if (declarator.type !== AST_NODE_TYPES8.VariableDeclarator || declarator.id.type !== AST_NODE_TYPES8.Identifier || declarator.init === null) {
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
const declaration = parentOf(declarator);
|
|
1194
|
+
if (declaration?.type !== AST_NODE_TYPES8.VariableDeclaration || declaration.kind !== "const") {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
return declarator.init;
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* True when the only reads of `variable` that could run before `cast` are the
|
|
1201
|
+
* `use` itself and other `as` casts, all in the declaring function. Any other
|
|
1202
|
+
* earlier read (a guard, a validator call, a mutation) might have checked the
|
|
1203
|
+
* value, so the cast is given the benefit of the doubt.
|
|
1204
|
+
*/
|
|
1205
|
+
untouchedBefore(variable, use, cast) {
|
|
1206
|
+
const home = variable.scope.variableScope;
|
|
1207
|
+
return variable.references.every((reference) => {
|
|
1208
|
+
if (reference.init === true) {
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
if (reference.from.variableScope !== home) {
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
const id = reference.identifier;
|
|
1215
|
+
if (id === use || id.range[0] >= cast.range[0]) {
|
|
1216
|
+
return true;
|
|
1217
|
+
}
|
|
1218
|
+
return parentOf(id)?.type === AST_NODE_TYPES8.TSAsExpression;
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
/** A call whose callee is listed in the `boundaries` option. */
|
|
1222
|
+
isConfiguredBoundary(node) {
|
|
1223
|
+
if (this.boundaries.size === 0) {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
const call = node.type === AST_NODE_TYPES8.AwaitExpression ? unwrap(node.argument) : node;
|
|
1227
|
+
if (call.type !== AST_NODE_TYPES8.CallExpression) {
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
const path3 = memberPath(call.callee);
|
|
1231
|
+
return path3 !== null && this.boundaries.has(path3);
|
|
1232
|
+
}
|
|
1233
|
+
/** `.get(...)` / `.getAll(...)` on a `URLSearchParams`. */
|
|
1234
|
+
isSearchParamsRead(node) {
|
|
1235
|
+
const receiver = methodCallReceiver(node, ["get", "getAll"]);
|
|
1236
|
+
return receiver !== null && this.isSearchParams(unwrap(receiver), /* @__PURE__ */ new Set());
|
|
1237
|
+
}
|
|
1238
|
+
isSearchParams(node, seen) {
|
|
1239
|
+
if (node.type === AST_NODE_TYPES8.NewExpression) {
|
|
1240
|
+
return node.callee.type === AST_NODE_TYPES8.Identifier && node.callee.name === "URLSearchParams";
|
|
1241
|
+
}
|
|
1242
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression) {
|
|
1243
|
+
return !node.computed && node.property.type === AST_NODE_TYPES8.Identifier && node.property.name === "searchParams";
|
|
1244
|
+
}
|
|
1245
|
+
if (node.type !== AST_NODE_TYPES8.Identifier) {
|
|
1246
|
+
return false;
|
|
1247
|
+
}
|
|
1248
|
+
if (node.name === "searchParams") {
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
const variable = this.resolve(node);
|
|
1252
|
+
if (variable === null || seen.has(variable)) {
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
seen.add(variable);
|
|
1256
|
+
const init = this.constInit(variable);
|
|
1257
|
+
return init !== null && this.isSearchParams(unwrap(init), seen);
|
|
1258
|
+
}
|
|
1259
|
+
/** `event.data` (or a `{ data }` destructured parameter) in a `message` listener. */
|
|
1260
|
+
isMessageEventData(node) {
|
|
1261
|
+
let identifier;
|
|
1262
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression) {
|
|
1263
|
+
if (node.computed || node.property.type !== AST_NODE_TYPES8.Identifier || node.property.name !== "data" || node.object.type !== AST_NODE_TYPES8.Identifier) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
identifier = node.object;
|
|
1267
|
+
} else if (node.type === AST_NODE_TYPES8.Identifier) {
|
|
1268
|
+
identifier = node;
|
|
1269
|
+
} else {
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
const variable = this.resolve(identifier);
|
|
1273
|
+
const param = variable === null ? null : parameterOf(variable);
|
|
1274
|
+
if (param === null) {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
const { fn, name } = param;
|
|
1278
|
+
const [first] = fn.params;
|
|
1279
|
+
if (first === void 0) {
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
const isParamItself = name === first;
|
|
1283
|
+
const isDestructuredData = first.type === AST_NODE_TYPES8.ObjectPattern && first.properties.some(
|
|
1284
|
+
(property) => property.type === AST_NODE_TYPES8.Property && !property.computed && property.key.type === AST_NODE_TYPES8.Identifier && property.key.name === "data" && property.value === name
|
|
1285
|
+
);
|
|
1286
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression ? !isParamItself : !isDestructuredData) {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
return isMessageListener(fn);
|
|
1290
|
+
}
|
|
1291
|
+
/** `block.input` where `block` is narrowed to an Anthropic `tool_use` content block. */
|
|
1292
|
+
isToolUseInput(node) {
|
|
1293
|
+
if (node.type !== AST_NODE_TYPES8.MemberExpression || node.computed || node.property.type !== AST_NODE_TYPES8.Identifier || node.property.name !== "input") {
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
const block = unwrap(node.object);
|
|
1297
|
+
const blockText = this.sourceCode.getText(block);
|
|
1298
|
+
if (this.isGuardedAsToolUse(node, blockText)) {
|
|
1299
|
+
return true;
|
|
1300
|
+
}
|
|
1301
|
+
if (block.type !== AST_NODE_TYPES8.Identifier) {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
const variable = this.resolve(block);
|
|
1305
|
+
if (variable === null) {
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
const init = this.constInit(variable);
|
|
1309
|
+
if (init !== null) {
|
|
1310
|
+
const receiver = methodCallReceiver(unwrap(init), ["find"]);
|
|
1311
|
+
const call = unwrap(init);
|
|
1312
|
+
return receiver !== null && call.type === AST_NODE_TYPES8.CallExpression && this.isToolUsePredicate(call.arguments[0]);
|
|
1313
|
+
}
|
|
1314
|
+
const param = parameterOf(variable);
|
|
1315
|
+
if (param !== null && param.fn.params[0] === param.name) {
|
|
1316
|
+
const call = parentOf(param.fn);
|
|
1317
|
+
if (call?.type !== AST_NODE_TYPES8.CallExpression || call.arguments[0] !== param.fn || methodCallReceiver(call, ["map", "flatMap", "forEach"]) === null) {
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
const filtered = unwrap(call.callee.object);
|
|
1321
|
+
return methodCallReceiver(filtered, ["filter"]) !== null && filtered.type === AST_NODE_TYPES8.CallExpression && this.isToolUsePredicate(filtered.arguments[0]);
|
|
1322
|
+
}
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
/** `(b) => b.type === 'tool_use'`, optionally with a type-predicate return. */
|
|
1326
|
+
isToolUsePredicate(node) {
|
|
1327
|
+
if (node?.type !== AST_NODE_TYPES8.ArrowFunctionExpression || node.body.type === AST_NODE_TYPES8.BlockStatement) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
const [param] = node.params;
|
|
1331
|
+
return param?.type === AST_NODE_TYPES8.Identifier && this.testProvesToolUse(node.body, param.name);
|
|
1332
|
+
}
|
|
1333
|
+
/** An enclosing `if` / `?:` / `&&` / `case 'tool_use':` narrows `blockText`. */
|
|
1334
|
+
isGuardedAsToolUse(from, blockText) {
|
|
1335
|
+
let child = from;
|
|
1336
|
+
for (let parent = parentOf(child); parent !== null; child = parent, parent = parentOf(child)) {
|
|
1337
|
+
switch (parent.type) {
|
|
1338
|
+
case AST_NODE_TYPES8.IfStatement:
|
|
1339
|
+
case AST_NODE_TYPES8.ConditionalExpression:
|
|
1340
|
+
if (parent.consequent === child && this.testProvesToolUse(parent.test, blockText)) {
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
break;
|
|
1344
|
+
case AST_NODE_TYPES8.LogicalExpression:
|
|
1345
|
+
if (parent.operator === "&&" && parent.right === child && this.testProvesToolUse(parent.left, blockText)) {
|
|
1346
|
+
return true;
|
|
1347
|
+
}
|
|
1348
|
+
break;
|
|
1349
|
+
case AST_NODE_TYPES8.SwitchCase: {
|
|
1350
|
+
const statement = parentOf(parent);
|
|
1351
|
+
if (parent.test?.type === AST_NODE_TYPES8.Literal && parent.test.value === "tool_use" && statement?.type === AST_NODE_TYPES8.SwitchStatement && this.sourceCode.getText(statement.discriminant) === `${blockText}.type`) {
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
default:
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
/** `<blockText>.type === 'tool_use'`, possibly one conjunct of an `&&` chain. */
|
|
1363
|
+
testProvesToolUse(test, blockText) {
|
|
1364
|
+
if (test.type === AST_NODE_TYPES8.LogicalExpression && test.operator === "&&") {
|
|
1365
|
+
return this.testProvesToolUse(test.left, blockText) || this.testProvesToolUse(test.right, blockText);
|
|
1366
|
+
}
|
|
1367
|
+
if (test.type !== AST_NODE_TYPES8.BinaryExpression || test.operator !== "===" && test.operator !== "==") {
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
const typeText = `${blockText}.type`;
|
|
1371
|
+
const isToolUse = (node) => node.type === AST_NODE_TYPES8.Literal && node.value === "tool_use";
|
|
1372
|
+
const isTypeRead = (node) => this.sourceCode.getText(unwrap(node)) === typeText || this.sourceCode.getText(node) === `${blockText}?.type`;
|
|
1373
|
+
return isTypeRead(test.left) && isToolUse(test.right) || isToolUse(test.left) && isTypeRead(test.right);
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
function parameterOf(variable) {
|
|
1377
|
+
const [def] = variable.defs;
|
|
1378
|
+
if (variable.defs.length !== 1 || def === void 0 || def.type !== "Parameter") {
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
const fn = def.node;
|
|
1382
|
+
if (fn.type !== AST_NODE_TYPES8.ArrowFunctionExpression && fn.type !== AST_NODE_TYPES8.FunctionDeclaration && fn.type !== AST_NODE_TYPES8.FunctionExpression) {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
return { fn, name: def.name };
|
|
1386
|
+
}
|
|
1387
|
+
function isMessageListener(fn) {
|
|
1388
|
+
const parent = parentOf(fn);
|
|
1389
|
+
if (parent?.type === AST_NODE_TYPES8.CallExpression) {
|
|
1390
|
+
const [event, listener] = parent.arguments;
|
|
1391
|
+
const callee = memberPath(parent.callee);
|
|
1392
|
+
return listener === fn && event?.type === AST_NODE_TYPES8.Literal && event.value === "message" && callee !== null && (callee === "addEventListener" || callee.endsWith(".addEventListener"));
|
|
1393
|
+
}
|
|
1394
|
+
if (parent?.type === AST_NODE_TYPES8.AssignmentExpression && parent.right === fn) {
|
|
1395
|
+
const target = memberPath(parent.left);
|
|
1396
|
+
return target !== null && (target === "onmessage" || target.endsWith(".onmessage"));
|
|
1397
|
+
}
|
|
1398
|
+
return false;
|
|
1107
1399
|
}
|
|
1108
1400
|
function isShapeClaim(annotation) {
|
|
1109
1401
|
if (annotation.type === AST_NODE_TYPES8.TSArrayType) {
|
|
1110
1402
|
return true;
|
|
1111
1403
|
}
|
|
1404
|
+
if (annotation.type === AST_NODE_TYPES8.TSUnionType || annotation.type === AST_NODE_TYPES8.TSIntersectionType) {
|
|
1405
|
+
return annotation.types.some(isShapeClaim);
|
|
1406
|
+
}
|
|
1112
1407
|
if (annotation.type === AST_NODE_TYPES8.TSTypeReference) {
|
|
1113
1408
|
return !(annotation.typeName.type === AST_NODE_TYPES8.Identifier && annotation.typeName.name === "const");
|
|
1114
1409
|
}
|
|
@@ -1119,22 +1414,22 @@ var requireSchemaParseAtBoundaryRule = createRule({
|
|
|
1119
1414
|
meta: {
|
|
1120
1415
|
type: "problem",
|
|
1121
1416
|
docs: {
|
|
1122
|
-
description: "Disallow asserting external boundary data with `as T` instead of parsing it at runtime. Flags `JSON.parse(
|
|
1417
|
+
description: "Disallow asserting external boundary data with `as T` instead of parsing it at runtime. Flags casts of `JSON.parse`, `res.json()`, web storage, URL search params, message-event data and LLM tool input, directly or through a `const`; use a zod/valibot parse."
|
|
1123
1418
|
},
|
|
1124
|
-
schema: [],
|
|
1419
|
+
schema: [optionSchema7],
|
|
1125
1420
|
messages: {
|
|
1126
1421
|
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."
|
|
1127
1422
|
}
|
|
1128
1423
|
},
|
|
1129
|
-
defaultOptions: [],
|
|
1130
|
-
create(context) {
|
|
1424
|
+
defaultOptions: [{}],
|
|
1425
|
+
create(context, [options]) {
|
|
1426
|
+
const matcher = new BoundaryMatcher(context.sourceCode, options.boundaries ?? []);
|
|
1131
1427
|
return {
|
|
1132
1428
|
TSAsExpression(node) {
|
|
1133
1429
|
if (!isShapeClaim(node.typeAnnotation)) {
|
|
1134
1430
|
return;
|
|
1135
1431
|
}
|
|
1136
|
-
|
|
1137
|
-
if (isJsonParseCall(expr) || isAwaitJsonCall(expr)) {
|
|
1432
|
+
if (matcher.isBoundary(node.expression, node, /* @__PURE__ */ new Set())) {
|
|
1138
1433
|
context.report({ node, messageId: "castedBoundaryData" });
|
|
1139
1434
|
}
|
|
1140
1435
|
}
|
|
@@ -1146,7 +1441,7 @@ var requireSchemaParseAtBoundaryRule = createRule({
|
|
|
1146
1441
|
import { AST_NODE_TYPES as AST_NODE_TYPES9 } from "@typescript-eslint/utils";
|
|
1147
1442
|
var RULE_NAME9 = "restrict-throw-to-taxonomy";
|
|
1148
1443
|
var DEFAULT_ALLOW = ["Error"];
|
|
1149
|
-
var
|
|
1444
|
+
var optionSchema8 = {
|
|
1150
1445
|
type: "object",
|
|
1151
1446
|
additionalProperties: false,
|
|
1152
1447
|
properties: {
|
|
@@ -1177,7 +1472,7 @@ var restrictThrowToTaxonomyRule = createRule({
|
|
|
1177
1472
|
docs: {
|
|
1178
1473
|
description: "Restrict `throw` to an approved error taxonomy. Flags throwing a non-allowlisted error class and throwing a non-Error value (string, object, number, ...)."
|
|
1179
1474
|
},
|
|
1180
|
-
schema: [
|
|
1475
|
+
schema: [optionSchema8],
|
|
1181
1476
|
messages: {
|
|
1182
1477
|
disallowedErrorClass: "Throw an error from your taxonomy, not `{{name}}`. Allowed: {{allowed}}. Add `{{name}}` to the `allow` option if it belongs to your taxonomy.",
|
|
1183
1478
|
nonErrorThrow: "Throw an Error from your taxonomy, not a bare {{kind}} value. A non-Error throw carries no stack or cause."
|
|
@@ -1232,7 +1527,7 @@ var ENUM_FACTORIES = /* @__PURE__ */ new Set(["enum", "nativeEnum"]);
|
|
|
1232
1527
|
var UNION = /* @__PURE__ */ new Set(["union"]);
|
|
1233
1528
|
var LITERAL = /* @__PURE__ */ new Set(["literal"]);
|
|
1234
1529
|
var STRING = /* @__PURE__ */ new Set(["string"]);
|
|
1235
|
-
var
|
|
1530
|
+
var optionSchema9 = {
|
|
1236
1531
|
type: "object",
|
|
1237
1532
|
additionalProperties: false,
|
|
1238
1533
|
properties: {
|
|
@@ -1267,7 +1562,7 @@ var schemaEnumFieldConsistencyRule = createRule({
|
|
|
1267
1562
|
docs: {
|
|
1268
1563
|
description: "Disallow a zod field that is an enum in one object schema of a module from being `z.string()` in another, which widens the wire type every consumer then narrows by hand."
|
|
1269
1564
|
},
|
|
1270
|
-
schema: [
|
|
1565
|
+
schema: [optionSchema9],
|
|
1271
1566
|
messages: {
|
|
1272
1567
|
widenedEnumField: "`{{field}}` is `z.string()` here but an enum on line {{line}} of this file. The widened type leaks `string` to every consumer, which then has to narrow or cast it. Use {{suggestion}} instead (and, if the stored data is free text, migrate it first)."
|
|
1273
1568
|
}
|
|
@@ -1522,7 +1817,7 @@ function catalogHasPrefix(catalog, prefix) {
|
|
|
1522
1817
|
import { AST_NODE_TYPES as AST_NODE_TYPES11 } from "@typescript-eslint/utils";
|
|
1523
1818
|
var UNRESOLVED = "unresolved";
|
|
1524
1819
|
var MAX_DEPTH = 8;
|
|
1525
|
-
function
|
|
1820
|
+
function unwrap2(node) {
|
|
1526
1821
|
let current = node;
|
|
1527
1822
|
while (current.type === AST_NODE_TYPES11.TSAsExpression || current.type === AST_NODE_TYPES11.TSSatisfiesExpression || current.type === AST_NODE_TYPES11.TSNonNullExpression) {
|
|
1528
1823
|
current = current.expression;
|
|
@@ -1530,7 +1825,7 @@ function unwrap(node) {
|
|
|
1530
1825
|
return current;
|
|
1531
1826
|
}
|
|
1532
1827
|
function staticString(node) {
|
|
1533
|
-
const inner =
|
|
1828
|
+
const inner = unwrap2(node);
|
|
1534
1829
|
if (inner.type === AST_NODE_TYPES11.Literal && typeof inner.value === "string") return inner.value;
|
|
1535
1830
|
if (inner.type === AST_NODE_TYPES11.TemplateLiteral && inner.expressions.length === 0) {
|
|
1536
1831
|
return inner.quasis[0]?.value.cooked ?? null;
|
|
@@ -1571,7 +1866,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1571
1866
|
}
|
|
1572
1867
|
function resolveNamespaces(node, depth = 0) {
|
|
1573
1868
|
if (node === void 0) return defaultBinding.namespaces;
|
|
1574
|
-
const inner =
|
|
1869
|
+
const inner = unwrap2(node);
|
|
1575
1870
|
const literal = staticString(inner);
|
|
1576
1871
|
if (literal !== null) return [literal];
|
|
1577
1872
|
if (inner.type === AST_NODE_TYPES11.Literal && inner.value === null) return defaultBinding.namespaces;
|
|
@@ -1596,7 +1891,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1596
1891
|
}
|
|
1597
1892
|
const definition = resolveVariable(node)?.defs[0];
|
|
1598
1893
|
if (definition?.type === "Variable" && definition.parent.kind === "const" && definition.node.id.type === AST_NODE_TYPES11.Identifier && definition.node.init !== null) {
|
|
1599
|
-
const init =
|
|
1894
|
+
const init = unwrap2(definition.node.init);
|
|
1600
1895
|
const literal = staticString(init);
|
|
1601
1896
|
if (literal !== null) return literal;
|
|
1602
1897
|
const chained = resolveIdentifierString(init, depth + 1);
|
|
@@ -1612,7 +1907,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1612
1907
|
}
|
|
1613
1908
|
function staticPrefix(node) {
|
|
1614
1909
|
if (node === void 0) return null;
|
|
1615
|
-
const inner =
|
|
1910
|
+
const inner = unwrap2(node);
|
|
1616
1911
|
if (inner.type === AST_NODE_TYPES11.Identifier && inner.name === "undefined") return null;
|
|
1617
1912
|
if (inner.type === AST_NODE_TYPES11.Literal && inner.value === null) return null;
|
|
1618
1913
|
return staticString(inner) ?? UNRESOLVED;
|
|
@@ -1623,7 +1918,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1623
1918
|
if (namespaces === UNRESOLVED) return UNRESOLVED;
|
|
1624
1919
|
let keyPrefix = null;
|
|
1625
1920
|
if (optionsArg !== void 0) {
|
|
1626
|
-
const options =
|
|
1921
|
+
const options = unwrap2(optionsArg);
|
|
1627
1922
|
if (options.type !== AST_NODE_TYPES11.ObjectExpression) return UNRESOLVED;
|
|
1628
1923
|
for (const property of options.properties) {
|
|
1629
1924
|
if (property.type !== AST_NODE_TYPES11.Property) return UNRESOLVED;
|
|
@@ -1649,11 +1944,11 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1649
1944
|
function hookCallOf(identifier) {
|
|
1650
1945
|
const definition = resolveVariable(identifier)?.defs[0];
|
|
1651
1946
|
if (definition?.type !== "Variable" || definition.node.id.type !== AST_NODE_TYPES11.Identifier) return null;
|
|
1652
|
-
const init = definition.node.init === null ? null :
|
|
1947
|
+
const init = definition.node.init === null ? null : unwrap2(definition.node.init);
|
|
1653
1948
|
return init !== null && isHookCall(init) ? init : null;
|
|
1654
1949
|
}
|
|
1655
1950
|
function bindingOfTSource(object) {
|
|
1656
|
-
const inner =
|
|
1951
|
+
const inner = unwrap2(object);
|
|
1657
1952
|
if (isHookCall(inner)) return bindingFromHook(inner);
|
|
1658
1953
|
if (inner.type === AST_NODE_TYPES11.Identifier) {
|
|
1659
1954
|
const hook = hookCallOf(inner);
|
|
@@ -1690,7 +1985,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1690
1985
|
}
|
|
1691
1986
|
function bindingFromDeclarator(declarator, name, depth) {
|
|
1692
1987
|
if (declarator.init === null) return null;
|
|
1693
|
-
const init =
|
|
1988
|
+
const init = unwrap2(declarator.init);
|
|
1694
1989
|
const id = declarator.id;
|
|
1695
1990
|
if (id.type === AST_NODE_TYPES11.Identifier) {
|
|
1696
1991
|
if (isGetFixedT(init)) return bindingFromGetFixedT(init);
|
|
@@ -1751,7 +2046,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1751
2046
|
function readCallOptions(node) {
|
|
1752
2047
|
const none = { namespaces: null, plural: false, context: false, returnObjects: false };
|
|
1753
2048
|
if (node === void 0) return none;
|
|
1754
|
-
const inner =
|
|
2049
|
+
const inner = unwrap2(node);
|
|
1755
2050
|
if (inner.type !== AST_NODE_TYPES11.ObjectExpression) return UNRESOLVED;
|
|
1756
2051
|
let namespaces = null;
|
|
1757
2052
|
let plural = false;
|
|
@@ -1770,7 +2065,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1770
2065
|
} else if (name === "context") {
|
|
1771
2066
|
context2 = true;
|
|
1772
2067
|
} else if (name === "returnObjects") {
|
|
1773
|
-
const value =
|
|
2068
|
+
const value = unwrap2(property.value);
|
|
1774
2069
|
returnObjects = !(value.type === AST_NODE_TYPES11.Literal && value.value === false);
|
|
1775
2070
|
}
|
|
1776
2071
|
}
|
|
@@ -1790,7 +2085,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1790
2085
|
return { namespaces, key: `${binding.keyPrefix}${keySeparator === false ? "" : keySeparator}${raw}` };
|
|
1791
2086
|
}
|
|
1792
2087
|
function emit(node, keyNode, binding, options) {
|
|
1793
|
-
const inner =
|
|
2088
|
+
const inner = unwrap2(keyNode);
|
|
1794
2089
|
const raws = [];
|
|
1795
2090
|
const single = staticString(inner);
|
|
1796
2091
|
if (single !== null) {
|
|
@@ -1913,7 +2208,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1913
2208
|
var RULE_NAME11 = "translation-key-exists";
|
|
1914
2209
|
var stringList = { type: "array", items: { type: "string", minLength: 1 }, uniqueItems: true };
|
|
1915
2210
|
var separator = { oneOf: [{ type: "string", minLength: 1 }, { type: "boolean", enum: [false] }] };
|
|
1916
|
-
var
|
|
2211
|
+
var optionSchema10 = {
|
|
1917
2212
|
type: "object",
|
|
1918
2213
|
additionalProperties: false,
|
|
1919
2214
|
properties: {
|
|
@@ -1975,9 +2270,10 @@ var translationKeyExistsRule = createRule({
|
|
|
1975
2270
|
meta: {
|
|
1976
2271
|
type: "problem",
|
|
1977
2272
|
docs: {
|
|
1978
|
-
description: "Require every static i18next / react-i18next translation key (`t(...)`, `i18n.t(...)`, `<Trans i18nKey>`) to exist in the catalog of the namespace in scope."
|
|
2273
|
+
description: "Require every static i18next / react-i18next translation key (`t(...)`, `i18n.t(...)`, `<Trans i18nKey>`) to exist in the catalog of the namespace in scope.",
|
|
2274
|
+
requiresOptions: true
|
|
1979
2275
|
},
|
|
1980
|
-
schema: [
|
|
2276
|
+
schema: [optionSchema10],
|
|
1981
2277
|
messages: {
|
|
1982
2278
|
missingKey: "Translation key `{{key}}` does not exist in namespace `{{namespace}}` ({{catalogs}}). It renders as the raw key at runtime: fix the key or add it to the catalog.",
|
|
1983
2279
|
missingKeyPrefix: "No key in namespace `{{namespace}}` ({{catalogs}}) starts with `{{prefix}}`, so this template key can never resolve.",
|
|
@@ -2066,7 +2362,7 @@ var translationKeyExistsRule = createRule({
|
|
|
2066
2362
|
// src/rules/wire-message-naming.ts
|
|
2067
2363
|
var RULE_NAME12 = "wire-message-naming";
|
|
2068
2364
|
var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
|
|
2069
|
-
var
|
|
2365
|
+
var optionSchema11 = {
|
|
2070
2366
|
type: "object",
|
|
2071
2367
|
additionalProperties: false,
|
|
2072
2368
|
properties: {
|
|
@@ -2114,7 +2410,7 @@ var wireMessageNamingRule = createRule({
|
|
|
2114
2410
|
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)."
|
|
2115
2411
|
},
|
|
2116
2412
|
fixable: "code",
|
|
2117
|
-
schema: [
|
|
2413
|
+
schema: [optionSchema11],
|
|
2118
2414
|
messages: {
|
|
2119
2415
|
typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
|
|
2120
2416
|
}
|
|
@@ -2154,7 +2450,7 @@ var RULE_NAME13 = "zod-schema-naming";
|
|
|
2154
2450
|
var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
|
|
2155
2451
|
var SUFFIX = "Schema";
|
|
2156
2452
|
var DEFAULT_ROLE_SUFFIXES2 = [];
|
|
2157
|
-
var
|
|
2453
|
+
var optionSchema12 = {
|
|
2158
2454
|
type: "object",
|
|
2159
2455
|
additionalProperties: false,
|
|
2160
2456
|
properties: {
|
|
@@ -2193,7 +2489,7 @@ var zodSchemaNamingRule = createRule({
|
|
|
2193
2489
|
docs: {
|
|
2194
2490
|
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>`)."
|
|
2195
2491
|
},
|
|
2196
|
-
schema: [
|
|
2492
|
+
schema: [optionSchema12],
|
|
2197
2493
|
messages: {
|
|
2198
2494
|
schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
|
|
2199
2495
|
missingType: "Schema `{{name}}` has no sibling `export type {{base}} = z.infer<typeof {{name}}>`. Export the inferred type instead of hand-authoring a duplicate."
|
|
@@ -2262,7 +2558,7 @@ var rules = {
|
|
|
2262
2558
|
|
|
2263
2559
|
// src/index.ts
|
|
2264
2560
|
var NAMESPACE = "noctcore-contracts";
|
|
2265
|
-
var VERSION = "0.
|
|
2561
|
+
var VERSION = "0.7.1";
|
|
2266
2562
|
var plugin = {
|
|
2267
2563
|
meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
|
|
2268
2564
|
rules,
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Every `process.env.FOO` / `import.meta.env.FOO` key must be declared in a schema file.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
⚙️ Opt-in: `off` in `recommended`; needs options (see Options) · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
An env var that is read in code but declared nowhere is config drift waiting to fail in production:
|
|
@@ -27,8 +31,12 @@ const url = process.env.DATABASE_URL;
|
|
|
27
31
|
const port = import.meta.env.PORT;
|
|
28
32
|
```
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
## What it does not flag
|
|
35
|
+
|
|
36
|
+
- Computed reads such as `process.env[dynamicKey]`: only static `.FOO` accesses are policed.
|
|
37
|
+
- Destructured reads (`const { FOO } = process.env`).
|
|
38
|
+
- Anything at all when `schema` is unset, or when the schema file cannot be read: the rule goes inert
|
|
39
|
+
rather than flagging every access.
|
|
32
40
|
|
|
33
41
|
## Options
|
|
34
42
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> A fetch response must be checked with `.ok` or a status comparison before `.json()` parses its body.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
✅ In `recommended` at `error` · 💭 Type information: not needed
|
|
7
|
+
<!-- end generated rule header -->
|
|
6
8
|
|
|
7
9
|
## Why
|
|
8
10
|
|
|
@@ -85,6 +87,13 @@ Three response shapes are tracked: `const res = await fetch(...)` then `res.json
|
|
|
85
87
|
Aliases work (`const ok = res.ok; if (!ok) throw ...`). A bare `if (res.status)` or
|
|
86
88
|
`typeof res.status === 'number'` is not a check: both are true for a 500.
|
|
87
89
|
|
|
90
|
+
## What it does not flag
|
|
91
|
+
|
|
92
|
+
Purely syntactic, no type information. A response assigned later (`let res; res = await fetch(...)`),
|
|
93
|
+
passed to another function, or returned from a wrapper that is not in `fetchFunctions` is not tracked.
|
|
94
|
+
A nested function that reuses the response's name is treated as the same binding. Clients whose
|
|
95
|
+
`.json()` already throws on a bad status (ky, for example) do not belong in `fetchFunctions`.
|
|
96
|
+
|
|
88
97
|
## Options
|
|
89
98
|
|
|
90
99
|
| Option | Type | Default | Meaning |
|
|
@@ -99,9 +108,12 @@ List every name you want tracked, including `fetch` itself:
|
|
|
99
108
|
}]
|
|
100
109
|
```
|
|
101
110
|
|
|
102
|
-
##
|
|
111
|
+
## When not to use it
|
|
103
112
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
Leave it off if your code never reads a raw `Response`, for example when every request goes through a
|
|
114
|
+
client whose `.json()` already throws on a bad status.
|
|
115
|
+
|
|
116
|
+
## Credits
|
|
117
|
+
|
|
118
|
+
Based on the `typescript-core/fetch-must-check-ok` rule from
|
|
119
|
+
[tsforge](https://github.com/boringstack-xyz/tsforge) (MIT).
|