@noctcore/eslint-plugin-contracts 0.6.1 โ 0.7.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 +1 -1
- package/dist/index.cjs +324 -31
- package/dist/index.d.cts +11 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.js +325 -32
- package/docs/rules/require-schema-parse-at-boundary.md +94 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Legend: ๐ง = autofixable ยท ๐ค = ships inert / `off` in `recommended` (enabl
|
|
|
51
51
|
| [`restrict-throw-to-taxonomy`](./docs/rules/restrict-throw-to-taxonomy.md) | `throw` only allowlisted error classes; ban throwing non-Error values. | | |
|
|
52
52
|
| [`require-registered-keys`](./docs/rules/require-registered-keys.md) | Key/name argument of a configured sink API must be an imported constant, not a raw string. | | ๐ค |
|
|
53
53
|
| [`env-var-schema-parity`](./docs/rules/env-var-schema-parity.md) | `process.env.FOO` / `import.meta.env.FOO` keys must be declared in a schema file. | | ๐ค |
|
|
54
|
-
| [`require-schema-parse-at-boundary`](./docs/rules/require-schema-parse-at-boundary.md) | Ban `
|
|
54
|
+
| [`require-schema-parse-at-boundary`](./docs/rules/require-schema-parse-at-boundary.md) | Ban `as T` on boundary reads (`JSON.parse`, `res.json()`, web storage, search params, message events, LLM tool input), directly or through a `const`; parse at runtime. | | ๐ค |
|
|
55
55
|
| [`schema-enum-field-consistency`](./docs/rules/schema-enum-field-consistency.md) | A field that is an enum in one zod object schema must not be `z.string()` in another schema of the same module. | | |
|
|
56
56
|
| [`fetch-must-check-ok`](./docs/rules/fetch-must-check-ok.md) | A fetch response must be checked with `.ok` or a status comparison before `.json()` parses its body. | | |
|
|
57
57
|
| [`translation-key-exists`](./docs/rules/translation-key-exists.md) | A static i18next / react-i18next key (`t(...)`, `i18n.t(...)`, `<Trans i18nKey>`) must exist in the catalog of the namespace in scope. | | ๐ค |
|
package/dist/index.cjs
CHANGED
|
@@ -1139,20 +1139,313 @@ var requireRegisteredKeysRule = createRule({
|
|
|
1139
1139
|
// src/rules/require-schema-parse-at-boundary.ts
|
|
1140
1140
|
var import_utils8 = require("@typescript-eslint/utils");
|
|
1141
1141
|
var RULE_NAME8 = "require-schema-parse-at-boundary";
|
|
1142
|
+
var optionSchema7 = {
|
|
1143
|
+
type: "object",
|
|
1144
|
+
additionalProperties: false,
|
|
1145
|
+
properties: {
|
|
1146
|
+
boundaries: {
|
|
1147
|
+
type: "array",
|
|
1148
|
+
items: { type: "string", minLength: 1 },
|
|
1149
|
+
uniqueItems: true
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
function parentOf(node) {
|
|
1154
|
+
return node.parent ?? null;
|
|
1155
|
+
}
|
|
1156
|
+
function memberPath(node) {
|
|
1157
|
+
if (node.type === import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1158
|
+
return node.name;
|
|
1159
|
+
}
|
|
1160
|
+
if (node.type === import_utils8.AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1161
|
+
const object = memberPath(node.object);
|
|
1162
|
+
return object === null ? null : `${object}.${node.property.name}`;
|
|
1163
|
+
}
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
function methodCallReceiver(node, names) {
|
|
1167
|
+
if (node.type === import_utils8.AST_NODE_TYPES.CallExpression && node.callee.type === import_utils8.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.property.type === import_utils8.AST_NODE_TYPES.Identifier && names.includes(node.callee.property.name)) {
|
|
1168
|
+
return node.callee.object;
|
|
1169
|
+
}
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
function unwrap(node) {
|
|
1173
|
+
let current = node;
|
|
1174
|
+
while (current.type === import_utils8.AST_NODE_TYPES.TSNonNullExpression || current.type === import_utils8.AST_NODE_TYPES.ChainExpression) {
|
|
1175
|
+
current = current.expression;
|
|
1176
|
+
}
|
|
1177
|
+
return current;
|
|
1178
|
+
}
|
|
1142
1179
|
function isJsonParseCall(node) {
|
|
1143
|
-
return node.type === import_utils8.AST_NODE_TYPES.CallExpression && node.callee
|
|
1180
|
+
return node.type === import_utils8.AST_NODE_TYPES.CallExpression && memberPath(node.callee) === "JSON.parse";
|
|
1144
1181
|
}
|
|
1145
1182
|
function isAwaitJsonCall(node) {
|
|
1146
1183
|
if (node.type !== import_utils8.AST_NODE_TYPES.AwaitExpression) {
|
|
1147
1184
|
return false;
|
|
1148
1185
|
}
|
|
1149
1186
|
const call = node.argument;
|
|
1150
|
-
return call
|
|
1187
|
+
return methodCallReceiver(call, ["json"]) !== null && call.type === import_utils8.AST_NODE_TYPES.CallExpression && call.arguments.length === 0;
|
|
1188
|
+
}
|
|
1189
|
+
function isStorageRead(node) {
|
|
1190
|
+
const receiver = methodCallReceiver(node, ["getItem"]);
|
|
1191
|
+
const path3 = receiver === null ? null : memberPath(receiver);
|
|
1192
|
+
if (path3 === null) {
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
const last = path3.slice(path3.lastIndexOf(".") + 1);
|
|
1196
|
+
return last === "localStorage" || last === "sessionStorage";
|
|
1197
|
+
}
|
|
1198
|
+
var BoundaryMatcher = class {
|
|
1199
|
+
sourceCode;
|
|
1200
|
+
boundaries;
|
|
1201
|
+
constructor(sourceCode, boundaries) {
|
|
1202
|
+
this.sourceCode = sourceCode;
|
|
1203
|
+
this.boundaries = new Set(boundaries);
|
|
1204
|
+
}
|
|
1205
|
+
/** True when `expr`, the operand of the cast at `cast`, is boundary data. */
|
|
1206
|
+
isBoundary(expr, cast, seen) {
|
|
1207
|
+
const node = unwrap(expr);
|
|
1208
|
+
if (isJsonParseCall(node) || isAwaitJsonCall(node) || isStorageRead(node) || this.isSearchParamsRead(node) || this.isConfiguredBoundary(node) || this.isMessageEventData(node) || this.isToolUseInput(node)) {
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
if (node.type !== import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
const variable = this.resolve(node);
|
|
1215
|
+
if (variable === null || seen.has(variable)) {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
seen.add(variable);
|
|
1219
|
+
const init = this.constInit(variable);
|
|
1220
|
+
return init !== null && this.untouchedBefore(variable, node, cast) && this.isBoundary(init, init, seen);
|
|
1221
|
+
}
|
|
1222
|
+
resolve(identifier) {
|
|
1223
|
+
return import_utils8.ASTUtils.findVariable(this.sourceCode.getScope(identifier), identifier) ?? null;
|
|
1224
|
+
}
|
|
1225
|
+
/** The initializer of a single `const name = <init>` declaration, or null. */
|
|
1226
|
+
constInit(variable) {
|
|
1227
|
+
const [def] = variable.defs;
|
|
1228
|
+
if (variable.defs.length !== 1 || def === void 0) {
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
const declarator = def.node;
|
|
1232
|
+
if (declarator.type !== import_utils8.AST_NODE_TYPES.VariableDeclarator || declarator.id.type !== import_utils8.AST_NODE_TYPES.Identifier || declarator.init === null) {
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
const declaration = parentOf(declarator);
|
|
1236
|
+
if (declaration?.type !== import_utils8.AST_NODE_TYPES.VariableDeclaration || declaration.kind !== "const") {
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
return declarator.init;
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* True when the only reads of `variable` that could run before `cast` are the
|
|
1243
|
+
* `use` itself and other `as` casts, all in the declaring function. Any other
|
|
1244
|
+
* earlier read (a guard, a validator call, a mutation) might have checked the
|
|
1245
|
+
* value, so the cast is given the benefit of the doubt.
|
|
1246
|
+
*/
|
|
1247
|
+
untouchedBefore(variable, use, cast) {
|
|
1248
|
+
const home = variable.scope.variableScope;
|
|
1249
|
+
return variable.references.every((reference) => {
|
|
1250
|
+
if (reference.init === true) {
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
if (reference.from.variableScope !== home) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
const id = reference.identifier;
|
|
1257
|
+
if (id === use || id.range[0] >= cast.range[0]) {
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
return parentOf(id)?.type === import_utils8.AST_NODE_TYPES.TSAsExpression;
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
/** A call whose callee is listed in the `boundaries` option. */
|
|
1264
|
+
isConfiguredBoundary(node) {
|
|
1265
|
+
if (this.boundaries.size === 0) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
const call = node.type === import_utils8.AST_NODE_TYPES.AwaitExpression ? unwrap(node.argument) : node;
|
|
1269
|
+
if (call.type !== import_utils8.AST_NODE_TYPES.CallExpression) {
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
const path3 = memberPath(call.callee);
|
|
1273
|
+
return path3 !== null && this.boundaries.has(path3);
|
|
1274
|
+
}
|
|
1275
|
+
/** `.get(...)` / `.getAll(...)` on a `URLSearchParams`. */
|
|
1276
|
+
isSearchParamsRead(node) {
|
|
1277
|
+
const receiver = methodCallReceiver(node, ["get", "getAll"]);
|
|
1278
|
+
return receiver !== null && this.isSearchParams(unwrap(receiver), /* @__PURE__ */ new Set());
|
|
1279
|
+
}
|
|
1280
|
+
isSearchParams(node, seen) {
|
|
1281
|
+
if (node.type === import_utils8.AST_NODE_TYPES.NewExpression) {
|
|
1282
|
+
return node.callee.type === import_utils8.AST_NODE_TYPES.Identifier && node.callee.name === "URLSearchParams";
|
|
1283
|
+
}
|
|
1284
|
+
if (node.type === import_utils8.AST_NODE_TYPES.MemberExpression) {
|
|
1285
|
+
return !node.computed && node.property.type === import_utils8.AST_NODE_TYPES.Identifier && node.property.name === "searchParams";
|
|
1286
|
+
}
|
|
1287
|
+
if (node.type !== import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
if (node.name === "searchParams") {
|
|
1291
|
+
return true;
|
|
1292
|
+
}
|
|
1293
|
+
const variable = this.resolve(node);
|
|
1294
|
+
if (variable === null || seen.has(variable)) {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
seen.add(variable);
|
|
1298
|
+
const init = this.constInit(variable);
|
|
1299
|
+
return init !== null && this.isSearchParams(unwrap(init), seen);
|
|
1300
|
+
}
|
|
1301
|
+
/** `event.data` (or a `{ data }` destructured parameter) in a `message` listener. */
|
|
1302
|
+
isMessageEventData(node) {
|
|
1303
|
+
let identifier;
|
|
1304
|
+
if (node.type === import_utils8.AST_NODE_TYPES.MemberExpression) {
|
|
1305
|
+
if (node.computed || node.property.type !== import_utils8.AST_NODE_TYPES.Identifier || node.property.name !== "data" || node.object.type !== import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
identifier = node.object;
|
|
1309
|
+
} else if (node.type === import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1310
|
+
identifier = node;
|
|
1311
|
+
} else {
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
const variable = this.resolve(identifier);
|
|
1315
|
+
const param = variable === null ? null : parameterOf(variable);
|
|
1316
|
+
if (param === null) {
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
const { fn, name } = param;
|
|
1320
|
+
const [first] = fn.params;
|
|
1321
|
+
if (first === void 0) {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
const isParamItself = name === first;
|
|
1325
|
+
const isDestructuredData = first.type === import_utils8.AST_NODE_TYPES.ObjectPattern && first.properties.some(
|
|
1326
|
+
(property) => property.type === import_utils8.AST_NODE_TYPES.Property && !property.computed && property.key.type === import_utils8.AST_NODE_TYPES.Identifier && property.key.name === "data" && property.value === name
|
|
1327
|
+
);
|
|
1328
|
+
if (node.type === import_utils8.AST_NODE_TYPES.MemberExpression ? !isParamItself : !isDestructuredData) {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
return isMessageListener(fn);
|
|
1332
|
+
}
|
|
1333
|
+
/** `block.input` where `block` is narrowed to an Anthropic `tool_use` content block. */
|
|
1334
|
+
isToolUseInput(node) {
|
|
1335
|
+
if (node.type !== import_utils8.AST_NODE_TYPES.MemberExpression || node.computed || node.property.type !== import_utils8.AST_NODE_TYPES.Identifier || node.property.name !== "input") {
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
const block = unwrap(node.object);
|
|
1339
|
+
const blockText = this.sourceCode.getText(block);
|
|
1340
|
+
if (this.isGuardedAsToolUse(node, blockText)) {
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
if (block.type !== import_utils8.AST_NODE_TYPES.Identifier) {
|
|
1344
|
+
return false;
|
|
1345
|
+
}
|
|
1346
|
+
const variable = this.resolve(block);
|
|
1347
|
+
if (variable === null) {
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
const init = this.constInit(variable);
|
|
1351
|
+
if (init !== null) {
|
|
1352
|
+
const receiver = methodCallReceiver(unwrap(init), ["find"]);
|
|
1353
|
+
const call = unwrap(init);
|
|
1354
|
+
return receiver !== null && call.type === import_utils8.AST_NODE_TYPES.CallExpression && this.isToolUsePredicate(call.arguments[0]);
|
|
1355
|
+
}
|
|
1356
|
+
const param = parameterOf(variable);
|
|
1357
|
+
if (param !== null && param.fn.params[0] === param.name) {
|
|
1358
|
+
const call = parentOf(param.fn);
|
|
1359
|
+
if (call?.type !== import_utils8.AST_NODE_TYPES.CallExpression || call.arguments[0] !== param.fn || methodCallReceiver(call, ["map", "flatMap", "forEach"]) === null) {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
const filtered = unwrap(call.callee.object);
|
|
1363
|
+
return methodCallReceiver(filtered, ["filter"]) !== null && filtered.type === import_utils8.AST_NODE_TYPES.CallExpression && this.isToolUsePredicate(filtered.arguments[0]);
|
|
1364
|
+
}
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1367
|
+
/** `(b) => b.type === 'tool_use'`, optionally with a type-predicate return. */
|
|
1368
|
+
isToolUsePredicate(node) {
|
|
1369
|
+
if (node?.type !== import_utils8.AST_NODE_TYPES.ArrowFunctionExpression || node.body.type === import_utils8.AST_NODE_TYPES.BlockStatement) {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
const [param] = node.params;
|
|
1373
|
+
return param?.type === import_utils8.AST_NODE_TYPES.Identifier && this.testProvesToolUse(node.body, param.name);
|
|
1374
|
+
}
|
|
1375
|
+
/** An enclosing `if` / `?:` / `&&` / `case 'tool_use':` narrows `blockText`. */
|
|
1376
|
+
isGuardedAsToolUse(from, blockText) {
|
|
1377
|
+
let child = from;
|
|
1378
|
+
for (let parent = parentOf(child); parent !== null; child = parent, parent = parentOf(child)) {
|
|
1379
|
+
switch (parent.type) {
|
|
1380
|
+
case import_utils8.AST_NODE_TYPES.IfStatement:
|
|
1381
|
+
case import_utils8.AST_NODE_TYPES.ConditionalExpression:
|
|
1382
|
+
if (parent.consequent === child && this.testProvesToolUse(parent.test, blockText)) {
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
break;
|
|
1386
|
+
case import_utils8.AST_NODE_TYPES.LogicalExpression:
|
|
1387
|
+
if (parent.operator === "&&" && parent.right === child && this.testProvesToolUse(parent.left, blockText)) {
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
break;
|
|
1391
|
+
case import_utils8.AST_NODE_TYPES.SwitchCase: {
|
|
1392
|
+
const statement = parentOf(parent);
|
|
1393
|
+
if (parent.test?.type === import_utils8.AST_NODE_TYPES.Literal && parent.test.value === "tool_use" && statement?.type === import_utils8.AST_NODE_TYPES.SwitchStatement && this.sourceCode.getText(statement.discriminant) === `${blockText}.type`) {
|
|
1394
|
+
return true;
|
|
1395
|
+
}
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
default:
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
/** `<blockText>.type === 'tool_use'`, possibly one conjunct of an `&&` chain. */
|
|
1405
|
+
testProvesToolUse(test, blockText) {
|
|
1406
|
+
if (test.type === import_utils8.AST_NODE_TYPES.LogicalExpression && test.operator === "&&") {
|
|
1407
|
+
return this.testProvesToolUse(test.left, blockText) || this.testProvesToolUse(test.right, blockText);
|
|
1408
|
+
}
|
|
1409
|
+
if (test.type !== import_utils8.AST_NODE_TYPES.BinaryExpression || test.operator !== "===" && test.operator !== "==") {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
const typeText = `${blockText}.type`;
|
|
1413
|
+
const isToolUse = (node) => node.type === import_utils8.AST_NODE_TYPES.Literal && node.value === "tool_use";
|
|
1414
|
+
const isTypeRead = (node) => this.sourceCode.getText(unwrap(node)) === typeText || this.sourceCode.getText(node) === `${blockText}?.type`;
|
|
1415
|
+
return isTypeRead(test.left) && isToolUse(test.right) || isToolUse(test.left) && isTypeRead(test.right);
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
function parameterOf(variable) {
|
|
1419
|
+
const [def] = variable.defs;
|
|
1420
|
+
if (variable.defs.length !== 1 || def === void 0 || def.type !== "Parameter") {
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
const fn = def.node;
|
|
1424
|
+
if (fn.type !== import_utils8.AST_NODE_TYPES.ArrowFunctionExpression && fn.type !== import_utils8.AST_NODE_TYPES.FunctionDeclaration && fn.type !== import_utils8.AST_NODE_TYPES.FunctionExpression) {
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
return { fn, name: def.name };
|
|
1428
|
+
}
|
|
1429
|
+
function isMessageListener(fn) {
|
|
1430
|
+
const parent = parentOf(fn);
|
|
1431
|
+
if (parent?.type === import_utils8.AST_NODE_TYPES.CallExpression) {
|
|
1432
|
+
const [event, listener] = parent.arguments;
|
|
1433
|
+
const callee = memberPath(parent.callee);
|
|
1434
|
+
return listener === fn && event?.type === import_utils8.AST_NODE_TYPES.Literal && event.value === "message" && callee !== null && (callee === "addEventListener" || callee.endsWith(".addEventListener"));
|
|
1435
|
+
}
|
|
1436
|
+
if (parent?.type === import_utils8.AST_NODE_TYPES.AssignmentExpression && parent.right === fn) {
|
|
1437
|
+
const target = memberPath(parent.left);
|
|
1438
|
+
return target !== null && (target === "onmessage" || target.endsWith(".onmessage"));
|
|
1439
|
+
}
|
|
1440
|
+
return false;
|
|
1151
1441
|
}
|
|
1152
1442
|
function isShapeClaim(annotation) {
|
|
1153
1443
|
if (annotation.type === import_utils8.AST_NODE_TYPES.TSArrayType) {
|
|
1154
1444
|
return true;
|
|
1155
1445
|
}
|
|
1446
|
+
if (annotation.type === import_utils8.AST_NODE_TYPES.TSUnionType || annotation.type === import_utils8.AST_NODE_TYPES.TSIntersectionType) {
|
|
1447
|
+
return annotation.types.some(isShapeClaim);
|
|
1448
|
+
}
|
|
1156
1449
|
if (annotation.type === import_utils8.AST_NODE_TYPES.TSTypeReference) {
|
|
1157
1450
|
return !(annotation.typeName.type === import_utils8.AST_NODE_TYPES.Identifier && annotation.typeName.name === "const");
|
|
1158
1451
|
}
|
|
@@ -1163,22 +1456,22 @@ var requireSchemaParseAtBoundaryRule = createRule({
|
|
|
1163
1456
|
meta: {
|
|
1164
1457
|
type: "problem",
|
|
1165
1458
|
docs: {
|
|
1166
|
-
description: "Disallow asserting external boundary data with `as T` instead of parsing it at runtime. Flags `JSON.parse(
|
|
1459
|
+
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."
|
|
1167
1460
|
},
|
|
1168
|
-
schema: [],
|
|
1461
|
+
schema: [optionSchema7],
|
|
1169
1462
|
messages: {
|
|
1170
1463
|
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."
|
|
1171
1464
|
}
|
|
1172
1465
|
},
|
|
1173
|
-
defaultOptions: [],
|
|
1174
|
-
create(context) {
|
|
1466
|
+
defaultOptions: [{}],
|
|
1467
|
+
create(context, [options]) {
|
|
1468
|
+
const matcher = new BoundaryMatcher(context.sourceCode, options.boundaries ?? []);
|
|
1175
1469
|
return {
|
|
1176
1470
|
TSAsExpression(node) {
|
|
1177
1471
|
if (!isShapeClaim(node.typeAnnotation)) {
|
|
1178
1472
|
return;
|
|
1179
1473
|
}
|
|
1180
|
-
|
|
1181
|
-
if (isJsonParseCall(expr) || isAwaitJsonCall(expr)) {
|
|
1474
|
+
if (matcher.isBoundary(node.expression, node, /* @__PURE__ */ new Set())) {
|
|
1182
1475
|
context.report({ node, messageId: "castedBoundaryData" });
|
|
1183
1476
|
}
|
|
1184
1477
|
}
|
|
@@ -1190,7 +1483,7 @@ var requireSchemaParseAtBoundaryRule = createRule({
|
|
|
1190
1483
|
var import_utils9 = require("@typescript-eslint/utils");
|
|
1191
1484
|
var RULE_NAME9 = "restrict-throw-to-taxonomy";
|
|
1192
1485
|
var DEFAULT_ALLOW = ["Error"];
|
|
1193
|
-
var
|
|
1486
|
+
var optionSchema8 = {
|
|
1194
1487
|
type: "object",
|
|
1195
1488
|
additionalProperties: false,
|
|
1196
1489
|
properties: {
|
|
@@ -1221,7 +1514,7 @@ var restrictThrowToTaxonomyRule = createRule({
|
|
|
1221
1514
|
docs: {
|
|
1222
1515
|
description: "Restrict `throw` to an approved error taxonomy. Flags throwing a non-allowlisted error class and throwing a non-Error value (string, object, number, ...)."
|
|
1223
1516
|
},
|
|
1224
|
-
schema: [
|
|
1517
|
+
schema: [optionSchema8],
|
|
1225
1518
|
messages: {
|
|
1226
1519
|
disallowedErrorClass: "Throw an error from your taxonomy, not `{{name}}`. Allowed: {{allowed}}. Add `{{name}}` to the `allow` option if it belongs to your taxonomy.",
|
|
1227
1520
|
nonErrorThrow: "Throw an Error from your taxonomy, not a bare {{kind}} value. A non-Error throw carries no stack or cause."
|
|
@@ -1276,7 +1569,7 @@ var ENUM_FACTORIES = /* @__PURE__ */ new Set(["enum", "nativeEnum"]);
|
|
|
1276
1569
|
var UNION = /* @__PURE__ */ new Set(["union"]);
|
|
1277
1570
|
var LITERAL = /* @__PURE__ */ new Set(["literal"]);
|
|
1278
1571
|
var STRING = /* @__PURE__ */ new Set(["string"]);
|
|
1279
|
-
var
|
|
1572
|
+
var optionSchema9 = {
|
|
1280
1573
|
type: "object",
|
|
1281
1574
|
additionalProperties: false,
|
|
1282
1575
|
properties: {
|
|
@@ -1311,7 +1604,7 @@ var schemaEnumFieldConsistencyRule = createRule({
|
|
|
1311
1604
|
docs: {
|
|
1312
1605
|
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."
|
|
1313
1606
|
},
|
|
1314
|
-
schema: [
|
|
1607
|
+
schema: [optionSchema9],
|
|
1315
1608
|
messages: {
|
|
1316
1609
|
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)."
|
|
1317
1610
|
}
|
|
@@ -1566,7 +1859,7 @@ function catalogHasPrefix(catalog, prefix) {
|
|
|
1566
1859
|
var import_utils11 = require("@typescript-eslint/utils");
|
|
1567
1860
|
var UNRESOLVED = "unresolved";
|
|
1568
1861
|
var MAX_DEPTH = 8;
|
|
1569
|
-
function
|
|
1862
|
+
function unwrap2(node) {
|
|
1570
1863
|
let current = node;
|
|
1571
1864
|
while (current.type === import_utils11.AST_NODE_TYPES.TSAsExpression || current.type === import_utils11.AST_NODE_TYPES.TSSatisfiesExpression || current.type === import_utils11.AST_NODE_TYPES.TSNonNullExpression) {
|
|
1572
1865
|
current = current.expression;
|
|
@@ -1574,7 +1867,7 @@ function unwrap(node) {
|
|
|
1574
1867
|
return current;
|
|
1575
1868
|
}
|
|
1576
1869
|
function staticString(node) {
|
|
1577
|
-
const inner =
|
|
1870
|
+
const inner = unwrap2(node);
|
|
1578
1871
|
if (inner.type === import_utils11.AST_NODE_TYPES.Literal && typeof inner.value === "string") return inner.value;
|
|
1579
1872
|
if (inner.type === import_utils11.AST_NODE_TYPES.TemplateLiteral && inner.expressions.length === 0) {
|
|
1580
1873
|
return inner.quasis[0]?.value.cooked ?? null;
|
|
@@ -1615,7 +1908,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1615
1908
|
}
|
|
1616
1909
|
function resolveNamespaces(node, depth = 0) {
|
|
1617
1910
|
if (node === void 0) return defaultBinding.namespaces;
|
|
1618
|
-
const inner =
|
|
1911
|
+
const inner = unwrap2(node);
|
|
1619
1912
|
const literal = staticString(inner);
|
|
1620
1913
|
if (literal !== null) return [literal];
|
|
1621
1914
|
if (inner.type === import_utils11.AST_NODE_TYPES.Literal && inner.value === null) return defaultBinding.namespaces;
|
|
@@ -1640,7 +1933,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1640
1933
|
}
|
|
1641
1934
|
const definition = resolveVariable(node)?.defs[0];
|
|
1642
1935
|
if (definition?.type === "Variable" && definition.parent.kind === "const" && definition.node.id.type === import_utils11.AST_NODE_TYPES.Identifier && definition.node.init !== null) {
|
|
1643
|
-
const init =
|
|
1936
|
+
const init = unwrap2(definition.node.init);
|
|
1644
1937
|
const literal = staticString(init);
|
|
1645
1938
|
if (literal !== null) return literal;
|
|
1646
1939
|
const chained = resolveIdentifierString(init, depth + 1);
|
|
@@ -1656,7 +1949,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1656
1949
|
}
|
|
1657
1950
|
function staticPrefix(node) {
|
|
1658
1951
|
if (node === void 0) return null;
|
|
1659
|
-
const inner =
|
|
1952
|
+
const inner = unwrap2(node);
|
|
1660
1953
|
if (inner.type === import_utils11.AST_NODE_TYPES.Identifier && inner.name === "undefined") return null;
|
|
1661
1954
|
if (inner.type === import_utils11.AST_NODE_TYPES.Literal && inner.value === null) return null;
|
|
1662
1955
|
return staticString(inner) ?? UNRESOLVED;
|
|
@@ -1667,7 +1960,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1667
1960
|
if (namespaces === UNRESOLVED) return UNRESOLVED;
|
|
1668
1961
|
let keyPrefix = null;
|
|
1669
1962
|
if (optionsArg !== void 0) {
|
|
1670
|
-
const options =
|
|
1963
|
+
const options = unwrap2(optionsArg);
|
|
1671
1964
|
if (options.type !== import_utils11.AST_NODE_TYPES.ObjectExpression) return UNRESOLVED;
|
|
1672
1965
|
for (const property of options.properties) {
|
|
1673
1966
|
if (property.type !== import_utils11.AST_NODE_TYPES.Property) return UNRESOLVED;
|
|
@@ -1693,11 +1986,11 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1693
1986
|
function hookCallOf(identifier) {
|
|
1694
1987
|
const definition = resolveVariable(identifier)?.defs[0];
|
|
1695
1988
|
if (definition?.type !== "Variable" || definition.node.id.type !== import_utils11.AST_NODE_TYPES.Identifier) return null;
|
|
1696
|
-
const init = definition.node.init === null ? null :
|
|
1989
|
+
const init = definition.node.init === null ? null : unwrap2(definition.node.init);
|
|
1697
1990
|
return init !== null && isHookCall(init) ? init : null;
|
|
1698
1991
|
}
|
|
1699
1992
|
function bindingOfTSource(object) {
|
|
1700
|
-
const inner =
|
|
1993
|
+
const inner = unwrap2(object);
|
|
1701
1994
|
if (isHookCall(inner)) return bindingFromHook(inner);
|
|
1702
1995
|
if (inner.type === import_utils11.AST_NODE_TYPES.Identifier) {
|
|
1703
1996
|
const hook = hookCallOf(inner);
|
|
@@ -1734,7 +2027,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1734
2027
|
}
|
|
1735
2028
|
function bindingFromDeclarator(declarator, name, depth) {
|
|
1736
2029
|
if (declarator.init === null) return null;
|
|
1737
|
-
const init =
|
|
2030
|
+
const init = unwrap2(declarator.init);
|
|
1738
2031
|
const id = declarator.id;
|
|
1739
2032
|
if (id.type === import_utils11.AST_NODE_TYPES.Identifier) {
|
|
1740
2033
|
if (isGetFixedT(init)) return bindingFromGetFixedT(init);
|
|
@@ -1795,7 +2088,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1795
2088
|
function readCallOptions(node) {
|
|
1796
2089
|
const none = { namespaces: null, plural: false, context: false, returnObjects: false };
|
|
1797
2090
|
if (node === void 0) return none;
|
|
1798
|
-
const inner =
|
|
2091
|
+
const inner = unwrap2(node);
|
|
1799
2092
|
if (inner.type !== import_utils11.AST_NODE_TYPES.ObjectExpression) return UNRESOLVED;
|
|
1800
2093
|
let namespaces = null;
|
|
1801
2094
|
let plural = false;
|
|
@@ -1814,7 +2107,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1814
2107
|
} else if (name === "context") {
|
|
1815
2108
|
context2 = true;
|
|
1816
2109
|
} else if (name === "returnObjects") {
|
|
1817
|
-
const value =
|
|
2110
|
+
const value = unwrap2(property.value);
|
|
1818
2111
|
returnObjects = !(value.type === import_utils11.AST_NODE_TYPES.Literal && value.value === false);
|
|
1819
2112
|
}
|
|
1820
2113
|
}
|
|
@@ -1834,7 +2127,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1834
2127
|
return { namespaces, key: `${binding.keyPrefix}${keySeparator === false ? "" : keySeparator}${raw}` };
|
|
1835
2128
|
}
|
|
1836
2129
|
function emit(node, keyNode, binding, options) {
|
|
1837
|
-
const inner =
|
|
2130
|
+
const inner = unwrap2(keyNode);
|
|
1838
2131
|
const raws = [];
|
|
1839
2132
|
const single = staticString(inner);
|
|
1840
2133
|
if (single !== null) {
|
|
@@ -1957,7 +2250,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1957
2250
|
var RULE_NAME11 = "translation-key-exists";
|
|
1958
2251
|
var stringList = { type: "array", items: { type: "string", minLength: 1 }, uniqueItems: true };
|
|
1959
2252
|
var separator = { oneOf: [{ type: "string", minLength: 1 }, { type: "boolean", enum: [false] }] };
|
|
1960
|
-
var
|
|
2253
|
+
var optionSchema10 = {
|
|
1961
2254
|
type: "object",
|
|
1962
2255
|
additionalProperties: false,
|
|
1963
2256
|
properties: {
|
|
@@ -2021,7 +2314,7 @@ var translationKeyExistsRule = createRule({
|
|
|
2021
2314
|
docs: {
|
|
2022
2315
|
description: "Require every static i18next / react-i18next translation key (`t(...)`, `i18n.t(...)`, `<Trans i18nKey>`) to exist in the catalog of the namespace in scope."
|
|
2023
2316
|
},
|
|
2024
|
-
schema: [
|
|
2317
|
+
schema: [optionSchema10],
|
|
2025
2318
|
messages: {
|
|
2026
2319
|
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.",
|
|
2027
2320
|
missingKeyPrefix: "No key in namespace `{{namespace}}` ({{catalogs}}) starts with `{{prefix}}`, so this template key can never resolve.",
|
|
@@ -2110,7 +2403,7 @@ var translationKeyExistsRule = createRule({
|
|
|
2110
2403
|
// src/rules/wire-message-naming.ts
|
|
2111
2404
|
var RULE_NAME12 = "wire-message-naming";
|
|
2112
2405
|
var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
|
|
2113
|
-
var
|
|
2406
|
+
var optionSchema11 = {
|
|
2114
2407
|
type: "object",
|
|
2115
2408
|
additionalProperties: false,
|
|
2116
2409
|
properties: {
|
|
@@ -2158,7 +2451,7 @@ var wireMessageNamingRule = createRule({
|
|
|
2158
2451
|
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)."
|
|
2159
2452
|
},
|
|
2160
2453
|
fixable: "code",
|
|
2161
|
-
schema: [
|
|
2454
|
+
schema: [optionSchema11],
|
|
2162
2455
|
messages: {
|
|
2163
2456
|
typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
|
|
2164
2457
|
}
|
|
@@ -2198,7 +2491,7 @@ var RULE_NAME13 = "zod-schema-naming";
|
|
|
2198
2491
|
var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
|
|
2199
2492
|
var SUFFIX = "Schema";
|
|
2200
2493
|
var DEFAULT_ROLE_SUFFIXES2 = [];
|
|
2201
|
-
var
|
|
2494
|
+
var optionSchema12 = {
|
|
2202
2495
|
type: "object",
|
|
2203
2496
|
additionalProperties: false,
|
|
2204
2497
|
properties: {
|
|
@@ -2237,7 +2530,7 @@ var zodSchemaNamingRule = createRule({
|
|
|
2237
2530
|
docs: {
|
|
2238
2531
|
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>`)."
|
|
2239
2532
|
},
|
|
2240
|
-
schema: [
|
|
2533
|
+
schema: [optionSchema12],
|
|
2241
2534
|
messages: {
|
|
2242
2535
|
schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
|
|
2243
2536
|
missingType: "Schema `{{name}}` has no sibling `export type {{base}} = z.infer<typeof {{name}}>`. Export the inferred type instead of hand-authoring a duplicate."
|
|
@@ -2306,7 +2599,7 @@ var rules = {
|
|
|
2306
2599
|
|
|
2307
2600
|
// src/index.ts
|
|
2308
2601
|
var NAMESPACE = "noctcore-contracts";
|
|
2309
|
-
var VERSION = "0.
|
|
2602
|
+
var VERSION = "0.7.0";
|
|
2310
2603
|
var plugin = {
|
|
2311
2604
|
meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
|
|
2312
2605
|
rules,
|
package/dist/index.d.cts
CHANGED
|
@@ -21,6 +21,15 @@ interface SchemaEnumFieldConsistencyOptions {
|
|
|
21
21
|
readonly enumIdentifierPattern?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
interface RequireSchemaParseAtBoundaryOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Extra callees whose result is boundary data: a bare name (`readBody`) or a
|
|
27
|
+
* dotted path (`ipcRenderer.invoke`). `readBody(event) as T` and
|
|
28
|
+
* `(await readBody(event)) as T` are then flagged like `JSON.parse`. Default `[]`.
|
|
29
|
+
*/
|
|
30
|
+
readonly boundaries?: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
interface EnvVarSchemaParityOptions {
|
|
25
34
|
/** Path to the env declaration source โ a `.env.example` or a zod-env module. Empty = rule is inert. */
|
|
26
35
|
readonly schema?: string;
|
|
@@ -274,7 +283,7 @@ declare const rules: {
|
|
|
274
283
|
'env-var-schema-parity': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredEnvVar", [EnvVarSchemaParityOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
275
284
|
name: string;
|
|
276
285
|
};
|
|
277
|
-
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
286
|
+
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [RequireSchemaParseAtBoundaryOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
278
287
|
name: string;
|
|
279
288
|
};
|
|
280
289
|
'schema-enum-field-consistency': _typescript_eslint_utils_ts_eslint.RuleModule<"widenedEnumField", [SchemaEnumFieldConsistencyOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
@@ -321,7 +330,7 @@ declare const plugin: {
|
|
|
321
330
|
'env-var-schema-parity': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredEnvVar", [EnvVarSchemaParityOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
322
331
|
name: string;
|
|
323
332
|
};
|
|
324
|
-
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
333
|
+
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [RequireSchemaParseAtBoundaryOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
325
334
|
name: string;
|
|
326
335
|
};
|
|
327
336
|
'schema-enum-field-consistency': _typescript_eslint_utils_ts_eslint.RuleModule<"widenedEnumField", [SchemaEnumFieldConsistencyOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,15 @@ interface SchemaEnumFieldConsistencyOptions {
|
|
|
21
21
|
readonly enumIdentifierPattern?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
interface RequireSchemaParseAtBoundaryOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Extra callees whose result is boundary data: a bare name (`readBody`) or a
|
|
27
|
+
* dotted path (`ipcRenderer.invoke`). `readBody(event) as T` and
|
|
28
|
+
* `(await readBody(event)) as T` are then flagged like `JSON.parse`. Default `[]`.
|
|
29
|
+
*/
|
|
30
|
+
readonly boundaries?: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
interface EnvVarSchemaParityOptions {
|
|
25
34
|
/** Path to the env declaration source โ a `.env.example` or a zod-env module. Empty = rule is inert. */
|
|
26
35
|
readonly schema?: string;
|
|
@@ -274,7 +283,7 @@ declare const rules: {
|
|
|
274
283
|
'env-var-schema-parity': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredEnvVar", [EnvVarSchemaParityOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
275
284
|
name: string;
|
|
276
285
|
};
|
|
277
|
-
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
286
|
+
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [RequireSchemaParseAtBoundaryOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
278
287
|
name: string;
|
|
279
288
|
};
|
|
280
289
|
'schema-enum-field-consistency': _typescript_eslint_utils_ts_eslint.RuleModule<"widenedEnumField", [SchemaEnumFieldConsistencyOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
@@ -321,7 +330,7 @@ declare const plugin: {
|
|
|
321
330
|
'env-var-schema-parity': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredEnvVar", [EnvVarSchemaParityOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
322
331
|
name: string;
|
|
323
332
|
};
|
|
324
|
-
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
333
|
+
'require-schema-parse-at-boundary': _typescript_eslint_utils_ts_eslint.RuleModule<"castedBoundaryData", [RequireSchemaParseAtBoundaryOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
325
334
|
name: string;
|
|
326
335
|
};
|
|
327
336
|
'schema-enum-field-consistency': _typescript_eslint_utils_ts_eslint.RuleModule<"widenedEnumField", [SchemaEnumFieldConsistencyOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
package/dist/index.js
CHANGED
|
@@ -1093,22 +1093,315 @@ var requireRegisteredKeysRule = createRule({
|
|
|
1093
1093
|
});
|
|
1094
1094
|
|
|
1095
1095
|
// src/rules/require-schema-parse-at-boundary.ts
|
|
1096
|
-
import { AST_NODE_TYPES as AST_NODE_TYPES8 } from "@typescript-eslint/utils";
|
|
1096
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES8, ASTUtils } from "@typescript-eslint/utils";
|
|
1097
1097
|
var RULE_NAME8 = "require-schema-parse-at-boundary";
|
|
1098
|
+
var optionSchema7 = {
|
|
1099
|
+
type: "object",
|
|
1100
|
+
additionalProperties: false,
|
|
1101
|
+
properties: {
|
|
1102
|
+
boundaries: {
|
|
1103
|
+
type: "array",
|
|
1104
|
+
items: { type: "string", minLength: 1 },
|
|
1105
|
+
uniqueItems: true
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
function parentOf(node) {
|
|
1110
|
+
return node.parent ?? null;
|
|
1111
|
+
}
|
|
1112
|
+
function memberPath(node) {
|
|
1113
|
+
if (node.type === AST_NODE_TYPES8.Identifier) {
|
|
1114
|
+
return node.name;
|
|
1115
|
+
}
|
|
1116
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES8.Identifier) {
|
|
1117
|
+
const object = memberPath(node.object);
|
|
1118
|
+
return object === null ? null : `${object}.${node.property.name}`;
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
function methodCallReceiver(node, names) {
|
|
1123
|
+
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)) {
|
|
1124
|
+
return node.callee.object;
|
|
1125
|
+
}
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
function unwrap(node) {
|
|
1129
|
+
let current = node;
|
|
1130
|
+
while (current.type === AST_NODE_TYPES8.TSNonNullExpression || current.type === AST_NODE_TYPES8.ChainExpression) {
|
|
1131
|
+
current = current.expression;
|
|
1132
|
+
}
|
|
1133
|
+
return current;
|
|
1134
|
+
}
|
|
1098
1135
|
function isJsonParseCall(node) {
|
|
1099
|
-
return node.type === AST_NODE_TYPES8.CallExpression && node.callee
|
|
1136
|
+
return node.type === AST_NODE_TYPES8.CallExpression && memberPath(node.callee) === "JSON.parse";
|
|
1100
1137
|
}
|
|
1101
1138
|
function isAwaitJsonCall(node) {
|
|
1102
1139
|
if (node.type !== AST_NODE_TYPES8.AwaitExpression) {
|
|
1103
1140
|
return false;
|
|
1104
1141
|
}
|
|
1105
1142
|
const call = node.argument;
|
|
1106
|
-
return call
|
|
1143
|
+
return methodCallReceiver(call, ["json"]) !== null && call.type === AST_NODE_TYPES8.CallExpression && call.arguments.length === 0;
|
|
1144
|
+
}
|
|
1145
|
+
function isStorageRead(node) {
|
|
1146
|
+
const receiver = methodCallReceiver(node, ["getItem"]);
|
|
1147
|
+
const path3 = receiver === null ? null : memberPath(receiver);
|
|
1148
|
+
if (path3 === null) {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
const last = path3.slice(path3.lastIndexOf(".") + 1);
|
|
1152
|
+
return last === "localStorage" || last === "sessionStorage";
|
|
1153
|
+
}
|
|
1154
|
+
var BoundaryMatcher = class {
|
|
1155
|
+
sourceCode;
|
|
1156
|
+
boundaries;
|
|
1157
|
+
constructor(sourceCode, boundaries) {
|
|
1158
|
+
this.sourceCode = sourceCode;
|
|
1159
|
+
this.boundaries = new Set(boundaries);
|
|
1160
|
+
}
|
|
1161
|
+
/** True when `expr`, the operand of the cast at `cast`, is boundary data. */
|
|
1162
|
+
isBoundary(expr, cast, seen) {
|
|
1163
|
+
const node = unwrap(expr);
|
|
1164
|
+
if (isJsonParseCall(node) || isAwaitJsonCall(node) || isStorageRead(node) || this.isSearchParamsRead(node) || this.isConfiguredBoundary(node) || this.isMessageEventData(node) || this.isToolUseInput(node)) {
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
if (node.type !== AST_NODE_TYPES8.Identifier) {
|
|
1168
|
+
return false;
|
|
1169
|
+
}
|
|
1170
|
+
const variable = this.resolve(node);
|
|
1171
|
+
if (variable === null || seen.has(variable)) {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
seen.add(variable);
|
|
1175
|
+
const init = this.constInit(variable);
|
|
1176
|
+
return init !== null && this.untouchedBefore(variable, node, cast) && this.isBoundary(init, init, seen);
|
|
1177
|
+
}
|
|
1178
|
+
resolve(identifier) {
|
|
1179
|
+
return ASTUtils.findVariable(this.sourceCode.getScope(identifier), identifier) ?? null;
|
|
1180
|
+
}
|
|
1181
|
+
/** The initializer of a single `const name = <init>` declaration, or null. */
|
|
1182
|
+
constInit(variable) {
|
|
1183
|
+
const [def] = variable.defs;
|
|
1184
|
+
if (variable.defs.length !== 1 || def === void 0) {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
const declarator = def.node;
|
|
1188
|
+
if (declarator.type !== AST_NODE_TYPES8.VariableDeclarator || declarator.id.type !== AST_NODE_TYPES8.Identifier || declarator.init === null) {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
const declaration = parentOf(declarator);
|
|
1192
|
+
if (declaration?.type !== AST_NODE_TYPES8.VariableDeclaration || declaration.kind !== "const") {
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
return declarator.init;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* True when the only reads of `variable` that could run before `cast` are the
|
|
1199
|
+
* `use` itself and other `as` casts, all in the declaring function. Any other
|
|
1200
|
+
* earlier read (a guard, a validator call, a mutation) might have checked the
|
|
1201
|
+
* value, so the cast is given the benefit of the doubt.
|
|
1202
|
+
*/
|
|
1203
|
+
untouchedBefore(variable, use, cast) {
|
|
1204
|
+
const home = variable.scope.variableScope;
|
|
1205
|
+
return variable.references.every((reference) => {
|
|
1206
|
+
if (reference.init === true) {
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
if (reference.from.variableScope !== home) {
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
const id = reference.identifier;
|
|
1213
|
+
if (id === use || id.range[0] >= cast.range[0]) {
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
return parentOf(id)?.type === AST_NODE_TYPES8.TSAsExpression;
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
/** A call whose callee is listed in the `boundaries` option. */
|
|
1220
|
+
isConfiguredBoundary(node) {
|
|
1221
|
+
if (this.boundaries.size === 0) {
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1224
|
+
const call = node.type === AST_NODE_TYPES8.AwaitExpression ? unwrap(node.argument) : node;
|
|
1225
|
+
if (call.type !== AST_NODE_TYPES8.CallExpression) {
|
|
1226
|
+
return false;
|
|
1227
|
+
}
|
|
1228
|
+
const path3 = memberPath(call.callee);
|
|
1229
|
+
return path3 !== null && this.boundaries.has(path3);
|
|
1230
|
+
}
|
|
1231
|
+
/** `.get(...)` / `.getAll(...)` on a `URLSearchParams`. */
|
|
1232
|
+
isSearchParamsRead(node) {
|
|
1233
|
+
const receiver = methodCallReceiver(node, ["get", "getAll"]);
|
|
1234
|
+
return receiver !== null && this.isSearchParams(unwrap(receiver), /* @__PURE__ */ new Set());
|
|
1235
|
+
}
|
|
1236
|
+
isSearchParams(node, seen) {
|
|
1237
|
+
if (node.type === AST_NODE_TYPES8.NewExpression) {
|
|
1238
|
+
return node.callee.type === AST_NODE_TYPES8.Identifier && node.callee.name === "URLSearchParams";
|
|
1239
|
+
}
|
|
1240
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression) {
|
|
1241
|
+
return !node.computed && node.property.type === AST_NODE_TYPES8.Identifier && node.property.name === "searchParams";
|
|
1242
|
+
}
|
|
1243
|
+
if (node.type !== AST_NODE_TYPES8.Identifier) {
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
if (node.name === "searchParams") {
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1249
|
+
const variable = this.resolve(node);
|
|
1250
|
+
if (variable === null || seen.has(variable)) {
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
seen.add(variable);
|
|
1254
|
+
const init = this.constInit(variable);
|
|
1255
|
+
return init !== null && this.isSearchParams(unwrap(init), seen);
|
|
1256
|
+
}
|
|
1257
|
+
/** `event.data` (or a `{ data }` destructured parameter) in a `message` listener. */
|
|
1258
|
+
isMessageEventData(node) {
|
|
1259
|
+
let identifier;
|
|
1260
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression) {
|
|
1261
|
+
if (node.computed || node.property.type !== AST_NODE_TYPES8.Identifier || node.property.name !== "data" || node.object.type !== AST_NODE_TYPES8.Identifier) {
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
identifier = node.object;
|
|
1265
|
+
} else if (node.type === AST_NODE_TYPES8.Identifier) {
|
|
1266
|
+
identifier = node;
|
|
1267
|
+
} else {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
const variable = this.resolve(identifier);
|
|
1271
|
+
const param = variable === null ? null : parameterOf(variable);
|
|
1272
|
+
if (param === null) {
|
|
1273
|
+
return false;
|
|
1274
|
+
}
|
|
1275
|
+
const { fn, name } = param;
|
|
1276
|
+
const [first] = fn.params;
|
|
1277
|
+
if (first === void 0) {
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
const isParamItself = name === first;
|
|
1281
|
+
const isDestructuredData = first.type === AST_NODE_TYPES8.ObjectPattern && first.properties.some(
|
|
1282
|
+
(property) => property.type === AST_NODE_TYPES8.Property && !property.computed && property.key.type === AST_NODE_TYPES8.Identifier && property.key.name === "data" && property.value === name
|
|
1283
|
+
);
|
|
1284
|
+
if (node.type === AST_NODE_TYPES8.MemberExpression ? !isParamItself : !isDestructuredData) {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
return isMessageListener(fn);
|
|
1288
|
+
}
|
|
1289
|
+
/** `block.input` where `block` is narrowed to an Anthropic `tool_use` content block. */
|
|
1290
|
+
isToolUseInput(node) {
|
|
1291
|
+
if (node.type !== AST_NODE_TYPES8.MemberExpression || node.computed || node.property.type !== AST_NODE_TYPES8.Identifier || node.property.name !== "input") {
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
const block = unwrap(node.object);
|
|
1295
|
+
const blockText = this.sourceCode.getText(block);
|
|
1296
|
+
if (this.isGuardedAsToolUse(node, blockText)) {
|
|
1297
|
+
return true;
|
|
1298
|
+
}
|
|
1299
|
+
if (block.type !== AST_NODE_TYPES8.Identifier) {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
const variable = this.resolve(block);
|
|
1303
|
+
if (variable === null) {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
const init = this.constInit(variable);
|
|
1307
|
+
if (init !== null) {
|
|
1308
|
+
const receiver = methodCallReceiver(unwrap(init), ["find"]);
|
|
1309
|
+
const call = unwrap(init);
|
|
1310
|
+
return receiver !== null && call.type === AST_NODE_TYPES8.CallExpression && this.isToolUsePredicate(call.arguments[0]);
|
|
1311
|
+
}
|
|
1312
|
+
const param = parameterOf(variable);
|
|
1313
|
+
if (param !== null && param.fn.params[0] === param.name) {
|
|
1314
|
+
const call = parentOf(param.fn);
|
|
1315
|
+
if (call?.type !== AST_NODE_TYPES8.CallExpression || call.arguments[0] !== param.fn || methodCallReceiver(call, ["map", "flatMap", "forEach"]) === null) {
|
|
1316
|
+
return false;
|
|
1317
|
+
}
|
|
1318
|
+
const filtered = unwrap(call.callee.object);
|
|
1319
|
+
return methodCallReceiver(filtered, ["filter"]) !== null && filtered.type === AST_NODE_TYPES8.CallExpression && this.isToolUsePredicate(filtered.arguments[0]);
|
|
1320
|
+
}
|
|
1321
|
+
return false;
|
|
1322
|
+
}
|
|
1323
|
+
/** `(b) => b.type === 'tool_use'`, optionally with a type-predicate return. */
|
|
1324
|
+
isToolUsePredicate(node) {
|
|
1325
|
+
if (node?.type !== AST_NODE_TYPES8.ArrowFunctionExpression || node.body.type === AST_NODE_TYPES8.BlockStatement) {
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
const [param] = node.params;
|
|
1329
|
+
return param?.type === AST_NODE_TYPES8.Identifier && this.testProvesToolUse(node.body, param.name);
|
|
1330
|
+
}
|
|
1331
|
+
/** An enclosing `if` / `?:` / `&&` / `case 'tool_use':` narrows `blockText`. */
|
|
1332
|
+
isGuardedAsToolUse(from, blockText) {
|
|
1333
|
+
let child = from;
|
|
1334
|
+
for (let parent = parentOf(child); parent !== null; child = parent, parent = parentOf(child)) {
|
|
1335
|
+
switch (parent.type) {
|
|
1336
|
+
case AST_NODE_TYPES8.IfStatement:
|
|
1337
|
+
case AST_NODE_TYPES8.ConditionalExpression:
|
|
1338
|
+
if (parent.consequent === child && this.testProvesToolUse(parent.test, blockText)) {
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
break;
|
|
1342
|
+
case AST_NODE_TYPES8.LogicalExpression:
|
|
1343
|
+
if (parent.operator === "&&" && parent.right === child && this.testProvesToolUse(parent.left, blockText)) {
|
|
1344
|
+
return true;
|
|
1345
|
+
}
|
|
1346
|
+
break;
|
|
1347
|
+
case AST_NODE_TYPES8.SwitchCase: {
|
|
1348
|
+
const statement = parentOf(parent);
|
|
1349
|
+
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`) {
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
default:
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
/** `<blockText>.type === 'tool_use'`, possibly one conjunct of an `&&` chain. */
|
|
1361
|
+
testProvesToolUse(test, blockText) {
|
|
1362
|
+
if (test.type === AST_NODE_TYPES8.LogicalExpression && test.operator === "&&") {
|
|
1363
|
+
return this.testProvesToolUse(test.left, blockText) || this.testProvesToolUse(test.right, blockText);
|
|
1364
|
+
}
|
|
1365
|
+
if (test.type !== AST_NODE_TYPES8.BinaryExpression || test.operator !== "===" && test.operator !== "==") {
|
|
1366
|
+
return false;
|
|
1367
|
+
}
|
|
1368
|
+
const typeText = `${blockText}.type`;
|
|
1369
|
+
const isToolUse = (node) => node.type === AST_NODE_TYPES8.Literal && node.value === "tool_use";
|
|
1370
|
+
const isTypeRead = (node) => this.sourceCode.getText(unwrap(node)) === typeText || this.sourceCode.getText(node) === `${blockText}?.type`;
|
|
1371
|
+
return isTypeRead(test.left) && isToolUse(test.right) || isToolUse(test.left) && isTypeRead(test.right);
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
function parameterOf(variable) {
|
|
1375
|
+
const [def] = variable.defs;
|
|
1376
|
+
if (variable.defs.length !== 1 || def === void 0 || def.type !== "Parameter") {
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
const fn = def.node;
|
|
1380
|
+
if (fn.type !== AST_NODE_TYPES8.ArrowFunctionExpression && fn.type !== AST_NODE_TYPES8.FunctionDeclaration && fn.type !== AST_NODE_TYPES8.FunctionExpression) {
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
return { fn, name: def.name };
|
|
1384
|
+
}
|
|
1385
|
+
function isMessageListener(fn) {
|
|
1386
|
+
const parent = parentOf(fn);
|
|
1387
|
+
if (parent?.type === AST_NODE_TYPES8.CallExpression) {
|
|
1388
|
+
const [event, listener] = parent.arguments;
|
|
1389
|
+
const callee = memberPath(parent.callee);
|
|
1390
|
+
return listener === fn && event?.type === AST_NODE_TYPES8.Literal && event.value === "message" && callee !== null && (callee === "addEventListener" || callee.endsWith(".addEventListener"));
|
|
1391
|
+
}
|
|
1392
|
+
if (parent?.type === AST_NODE_TYPES8.AssignmentExpression && parent.right === fn) {
|
|
1393
|
+
const target = memberPath(parent.left);
|
|
1394
|
+
return target !== null && (target === "onmessage" || target.endsWith(".onmessage"));
|
|
1395
|
+
}
|
|
1396
|
+
return false;
|
|
1107
1397
|
}
|
|
1108
1398
|
function isShapeClaim(annotation) {
|
|
1109
1399
|
if (annotation.type === AST_NODE_TYPES8.TSArrayType) {
|
|
1110
1400
|
return true;
|
|
1111
1401
|
}
|
|
1402
|
+
if (annotation.type === AST_NODE_TYPES8.TSUnionType || annotation.type === AST_NODE_TYPES8.TSIntersectionType) {
|
|
1403
|
+
return annotation.types.some(isShapeClaim);
|
|
1404
|
+
}
|
|
1112
1405
|
if (annotation.type === AST_NODE_TYPES8.TSTypeReference) {
|
|
1113
1406
|
return !(annotation.typeName.type === AST_NODE_TYPES8.Identifier && annotation.typeName.name === "const");
|
|
1114
1407
|
}
|
|
@@ -1119,22 +1412,22 @@ var requireSchemaParseAtBoundaryRule = createRule({
|
|
|
1119
1412
|
meta: {
|
|
1120
1413
|
type: "problem",
|
|
1121
1414
|
docs: {
|
|
1122
|
-
description: "Disallow asserting external boundary data with `as T` instead of parsing it at runtime. Flags `JSON.parse(
|
|
1415
|
+
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
1416
|
},
|
|
1124
|
-
schema: [],
|
|
1417
|
+
schema: [optionSchema7],
|
|
1125
1418
|
messages: {
|
|
1126
1419
|
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
1420
|
}
|
|
1128
1421
|
},
|
|
1129
|
-
defaultOptions: [],
|
|
1130
|
-
create(context) {
|
|
1422
|
+
defaultOptions: [{}],
|
|
1423
|
+
create(context, [options]) {
|
|
1424
|
+
const matcher = new BoundaryMatcher(context.sourceCode, options.boundaries ?? []);
|
|
1131
1425
|
return {
|
|
1132
1426
|
TSAsExpression(node) {
|
|
1133
1427
|
if (!isShapeClaim(node.typeAnnotation)) {
|
|
1134
1428
|
return;
|
|
1135
1429
|
}
|
|
1136
|
-
|
|
1137
|
-
if (isJsonParseCall(expr) || isAwaitJsonCall(expr)) {
|
|
1430
|
+
if (matcher.isBoundary(node.expression, node, /* @__PURE__ */ new Set())) {
|
|
1138
1431
|
context.report({ node, messageId: "castedBoundaryData" });
|
|
1139
1432
|
}
|
|
1140
1433
|
}
|
|
@@ -1146,7 +1439,7 @@ var requireSchemaParseAtBoundaryRule = createRule({
|
|
|
1146
1439
|
import { AST_NODE_TYPES as AST_NODE_TYPES9 } from "@typescript-eslint/utils";
|
|
1147
1440
|
var RULE_NAME9 = "restrict-throw-to-taxonomy";
|
|
1148
1441
|
var DEFAULT_ALLOW = ["Error"];
|
|
1149
|
-
var
|
|
1442
|
+
var optionSchema8 = {
|
|
1150
1443
|
type: "object",
|
|
1151
1444
|
additionalProperties: false,
|
|
1152
1445
|
properties: {
|
|
@@ -1177,7 +1470,7 @@ var restrictThrowToTaxonomyRule = createRule({
|
|
|
1177
1470
|
docs: {
|
|
1178
1471
|
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
1472
|
},
|
|
1180
|
-
schema: [
|
|
1473
|
+
schema: [optionSchema8],
|
|
1181
1474
|
messages: {
|
|
1182
1475
|
disallowedErrorClass: "Throw an error from your taxonomy, not `{{name}}`. Allowed: {{allowed}}. Add `{{name}}` to the `allow` option if it belongs to your taxonomy.",
|
|
1183
1476
|
nonErrorThrow: "Throw an Error from your taxonomy, not a bare {{kind}} value. A non-Error throw carries no stack or cause."
|
|
@@ -1232,7 +1525,7 @@ var ENUM_FACTORIES = /* @__PURE__ */ new Set(["enum", "nativeEnum"]);
|
|
|
1232
1525
|
var UNION = /* @__PURE__ */ new Set(["union"]);
|
|
1233
1526
|
var LITERAL = /* @__PURE__ */ new Set(["literal"]);
|
|
1234
1527
|
var STRING = /* @__PURE__ */ new Set(["string"]);
|
|
1235
|
-
var
|
|
1528
|
+
var optionSchema9 = {
|
|
1236
1529
|
type: "object",
|
|
1237
1530
|
additionalProperties: false,
|
|
1238
1531
|
properties: {
|
|
@@ -1267,7 +1560,7 @@ var schemaEnumFieldConsistencyRule = createRule({
|
|
|
1267
1560
|
docs: {
|
|
1268
1561
|
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
1562
|
},
|
|
1270
|
-
schema: [
|
|
1563
|
+
schema: [optionSchema9],
|
|
1271
1564
|
messages: {
|
|
1272
1565
|
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
1566
|
}
|
|
@@ -1522,7 +1815,7 @@ function catalogHasPrefix(catalog, prefix) {
|
|
|
1522
1815
|
import { AST_NODE_TYPES as AST_NODE_TYPES11 } from "@typescript-eslint/utils";
|
|
1523
1816
|
var UNRESOLVED = "unresolved";
|
|
1524
1817
|
var MAX_DEPTH = 8;
|
|
1525
|
-
function
|
|
1818
|
+
function unwrap2(node) {
|
|
1526
1819
|
let current = node;
|
|
1527
1820
|
while (current.type === AST_NODE_TYPES11.TSAsExpression || current.type === AST_NODE_TYPES11.TSSatisfiesExpression || current.type === AST_NODE_TYPES11.TSNonNullExpression) {
|
|
1528
1821
|
current = current.expression;
|
|
@@ -1530,7 +1823,7 @@ function unwrap(node) {
|
|
|
1530
1823
|
return current;
|
|
1531
1824
|
}
|
|
1532
1825
|
function staticString(node) {
|
|
1533
|
-
const inner =
|
|
1826
|
+
const inner = unwrap2(node);
|
|
1534
1827
|
if (inner.type === AST_NODE_TYPES11.Literal && typeof inner.value === "string") return inner.value;
|
|
1535
1828
|
if (inner.type === AST_NODE_TYPES11.TemplateLiteral && inner.expressions.length === 0) {
|
|
1536
1829
|
return inner.quasis[0]?.value.cooked ?? null;
|
|
@@ -1571,7 +1864,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1571
1864
|
}
|
|
1572
1865
|
function resolveNamespaces(node, depth = 0) {
|
|
1573
1866
|
if (node === void 0) return defaultBinding.namespaces;
|
|
1574
|
-
const inner =
|
|
1867
|
+
const inner = unwrap2(node);
|
|
1575
1868
|
const literal = staticString(inner);
|
|
1576
1869
|
if (literal !== null) return [literal];
|
|
1577
1870
|
if (inner.type === AST_NODE_TYPES11.Literal && inner.value === null) return defaultBinding.namespaces;
|
|
@@ -1596,7 +1889,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1596
1889
|
}
|
|
1597
1890
|
const definition = resolveVariable(node)?.defs[0];
|
|
1598
1891
|
if (definition?.type === "Variable" && definition.parent.kind === "const" && definition.node.id.type === AST_NODE_TYPES11.Identifier && definition.node.init !== null) {
|
|
1599
|
-
const init =
|
|
1892
|
+
const init = unwrap2(definition.node.init);
|
|
1600
1893
|
const literal = staticString(init);
|
|
1601
1894
|
if (literal !== null) return literal;
|
|
1602
1895
|
const chained = resolveIdentifierString(init, depth + 1);
|
|
@@ -1612,7 +1905,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1612
1905
|
}
|
|
1613
1906
|
function staticPrefix(node) {
|
|
1614
1907
|
if (node === void 0) return null;
|
|
1615
|
-
const inner =
|
|
1908
|
+
const inner = unwrap2(node);
|
|
1616
1909
|
if (inner.type === AST_NODE_TYPES11.Identifier && inner.name === "undefined") return null;
|
|
1617
1910
|
if (inner.type === AST_NODE_TYPES11.Literal && inner.value === null) return null;
|
|
1618
1911
|
return staticString(inner) ?? UNRESOLVED;
|
|
@@ -1623,7 +1916,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1623
1916
|
if (namespaces === UNRESOLVED) return UNRESOLVED;
|
|
1624
1917
|
let keyPrefix = null;
|
|
1625
1918
|
if (optionsArg !== void 0) {
|
|
1626
|
-
const options =
|
|
1919
|
+
const options = unwrap2(optionsArg);
|
|
1627
1920
|
if (options.type !== AST_NODE_TYPES11.ObjectExpression) return UNRESOLVED;
|
|
1628
1921
|
for (const property of options.properties) {
|
|
1629
1922
|
if (property.type !== AST_NODE_TYPES11.Property) return UNRESOLVED;
|
|
@@ -1649,11 +1942,11 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1649
1942
|
function hookCallOf(identifier) {
|
|
1650
1943
|
const definition = resolveVariable(identifier)?.defs[0];
|
|
1651
1944
|
if (definition?.type !== "Variable" || definition.node.id.type !== AST_NODE_TYPES11.Identifier) return null;
|
|
1652
|
-
const init = definition.node.init === null ? null :
|
|
1945
|
+
const init = definition.node.init === null ? null : unwrap2(definition.node.init);
|
|
1653
1946
|
return init !== null && isHookCall(init) ? init : null;
|
|
1654
1947
|
}
|
|
1655
1948
|
function bindingOfTSource(object) {
|
|
1656
|
-
const inner =
|
|
1949
|
+
const inner = unwrap2(object);
|
|
1657
1950
|
if (isHookCall(inner)) return bindingFromHook(inner);
|
|
1658
1951
|
if (inner.type === AST_NODE_TYPES11.Identifier) {
|
|
1659
1952
|
const hook = hookCallOf(inner);
|
|
@@ -1690,7 +1983,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1690
1983
|
}
|
|
1691
1984
|
function bindingFromDeclarator(declarator, name, depth) {
|
|
1692
1985
|
if (declarator.init === null) return null;
|
|
1693
|
-
const init =
|
|
1986
|
+
const init = unwrap2(declarator.init);
|
|
1694
1987
|
const id = declarator.id;
|
|
1695
1988
|
if (id.type === AST_NODE_TYPES11.Identifier) {
|
|
1696
1989
|
if (isGetFixedT(init)) return bindingFromGetFixedT(init);
|
|
@@ -1751,7 +2044,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1751
2044
|
function readCallOptions(node) {
|
|
1752
2045
|
const none = { namespaces: null, plural: false, context: false, returnObjects: false };
|
|
1753
2046
|
if (node === void 0) return none;
|
|
1754
|
-
const inner =
|
|
2047
|
+
const inner = unwrap2(node);
|
|
1755
2048
|
if (inner.type !== AST_NODE_TYPES11.ObjectExpression) return UNRESOLVED;
|
|
1756
2049
|
let namespaces = null;
|
|
1757
2050
|
let plural = false;
|
|
@@ -1770,7 +2063,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1770
2063
|
} else if (name === "context") {
|
|
1771
2064
|
context2 = true;
|
|
1772
2065
|
} else if (name === "returnObjects") {
|
|
1773
|
-
const value =
|
|
2066
|
+
const value = unwrap2(property.value);
|
|
1774
2067
|
returnObjects = !(value.type === AST_NODE_TYPES11.Literal && value.value === false);
|
|
1775
2068
|
}
|
|
1776
2069
|
}
|
|
@@ -1790,7 +2083,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1790
2083
|
return { namespaces, key: `${binding.keyPrefix}${keySeparator === false ? "" : keySeparator}${raw}` };
|
|
1791
2084
|
}
|
|
1792
2085
|
function emit(node, keyNode, binding, options) {
|
|
1793
|
-
const inner =
|
|
2086
|
+
const inner = unwrap2(keyNode);
|
|
1794
2087
|
const raws = [];
|
|
1795
2088
|
const single = staticString(inner);
|
|
1796
2089
|
if (single !== null) {
|
|
@@ -1913,7 +2206,7 @@ function createTranslationVisitor(context, settings, onUsage) {
|
|
|
1913
2206
|
var RULE_NAME11 = "translation-key-exists";
|
|
1914
2207
|
var stringList = { type: "array", items: { type: "string", minLength: 1 }, uniqueItems: true };
|
|
1915
2208
|
var separator = { oneOf: [{ type: "string", minLength: 1 }, { type: "boolean", enum: [false] }] };
|
|
1916
|
-
var
|
|
2209
|
+
var optionSchema10 = {
|
|
1917
2210
|
type: "object",
|
|
1918
2211
|
additionalProperties: false,
|
|
1919
2212
|
properties: {
|
|
@@ -1977,7 +2270,7 @@ var translationKeyExistsRule = createRule({
|
|
|
1977
2270
|
docs: {
|
|
1978
2271
|
description: "Require every static i18next / react-i18next translation key (`t(...)`, `i18n.t(...)`, `<Trans i18nKey>`) to exist in the catalog of the namespace in scope."
|
|
1979
2272
|
},
|
|
1980
|
-
schema: [
|
|
2273
|
+
schema: [optionSchema10],
|
|
1981
2274
|
messages: {
|
|
1982
2275
|
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
2276
|
missingKeyPrefix: "No key in namespace `{{namespace}}` ({{catalogs}}) starts with `{{prefix}}`, so this template key can never resolve.",
|
|
@@ -2066,7 +2359,7 @@ var translationKeyExistsRule = createRule({
|
|
|
2066
2359
|
// src/rules/wire-message-naming.ts
|
|
2067
2360
|
var RULE_NAME12 = "wire-message-naming";
|
|
2068
2361
|
var DEFAULT_ROLE_SUFFIXES = ["Event", "Command", "Query"];
|
|
2069
|
-
var
|
|
2362
|
+
var optionSchema11 = {
|
|
2070
2363
|
type: "object",
|
|
2071
2364
|
additionalProperties: false,
|
|
2072
2365
|
properties: {
|
|
@@ -2114,7 +2407,7 @@ var wireMessageNamingRule = createRule({
|
|
|
2114
2407
|
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
2408
|
},
|
|
2116
2409
|
fixable: "code",
|
|
2117
|
-
schema: [
|
|
2410
|
+
schema: [optionSchema11],
|
|
2118
2411
|
messages: {
|
|
2119
2412
|
typeMismatch: "Wire `type` literal '{{actual}}' for `{{name}}` must be '{{expected}}' \u2014 kebab-case of the const name minus its role suffix."
|
|
2120
2413
|
}
|
|
@@ -2154,7 +2447,7 @@ var RULE_NAME13 = "zod-schema-naming";
|
|
|
2154
2447
|
var SCHEMA_NAME = /^[A-Z][A-Za-z0-9]*Schema$/;
|
|
2155
2448
|
var SUFFIX = "Schema";
|
|
2156
2449
|
var DEFAULT_ROLE_SUFFIXES2 = [];
|
|
2157
|
-
var
|
|
2450
|
+
var optionSchema12 = {
|
|
2158
2451
|
type: "object",
|
|
2159
2452
|
additionalProperties: false,
|
|
2160
2453
|
properties: {
|
|
@@ -2193,7 +2486,7 @@ var zodSchemaNamingRule = createRule({
|
|
|
2193
2486
|
docs: {
|
|
2194
2487
|
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
2488
|
},
|
|
2196
|
-
schema: [
|
|
2489
|
+
schema: [optionSchema12],
|
|
2197
2490
|
messages: {
|
|
2198
2491
|
schemaNaming: "Exported zod schema `{{name}}` must be a PascalCase const ending in `Schema` (e.g. `FooSchema`).",
|
|
2199
2492
|
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 +2555,7 @@ var rules = {
|
|
|
2262
2555
|
|
|
2263
2556
|
// src/index.ts
|
|
2264
2557
|
var NAMESPACE = "noctcore-contracts";
|
|
2265
|
-
var VERSION = "0.
|
|
2558
|
+
var VERSION = "0.7.0";
|
|
2266
2559
|
var plugin = {
|
|
2267
2560
|
meta: { name: "@noctcore/eslint-plugin-contracts", version: VERSION },
|
|
2268
2561
|
rules,
|
|
@@ -17,7 +17,8 @@ const user = UserSchema.parse(await res.json()); // validated
|
|
|
17
17
|
## What it flags
|
|
18
18
|
|
|
19
19
|
This is a **conservative syntactic slice** of a concept that is fully general only with type
|
|
20
|
-
information. It flags a cast
|
|
20
|
+
information. It flags a cast whose target is a **shape claim** (a named type, an array, or a union
|
|
21
|
+
containing one: `as User`, `as User[]`, `as User | null`) applied to a boundary read:
|
|
21
22
|
|
|
22
23
|
```ts bad reports=3
|
|
23
24
|
const user = JSON.parse(raw) as User;
|
|
@@ -27,18 +28,104 @@ const fetched = (await res.json()) as User;
|
|
|
27
28
|
|
|
28
29
|
```ts good
|
|
29
30
|
const user = UserSchema.parse(JSON.parse(raw));
|
|
31
|
+
const users = UserSchema.array().parse(JSON.parse(raw));
|
|
30
32
|
const fetched = UserSchema.parse(await res.json());
|
|
31
|
-
const data = JSON.parse(raw) as unknown; // safe widening, not a shape claim
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
The boundary reads it knows:
|
|
36
|
+
|
|
37
|
+
| Source | Example |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `JSON.parse(...)` | covers web storage and OpenAI `call.function.arguments` fed to it |
|
|
40
|
+
| `await <expr>.json()` | a fetch `Response` body |
|
|
41
|
+
| `localStorage.getItem` / `sessionStorage.getItem` | also through `window.` / `globalThis.` |
|
|
42
|
+
| `URLSearchParams` `.get` / `.getAll` | `new URLSearchParams(...)`, `<x>.searchParams`, a `searchParams` binding, or a `const` bound to one of those |
|
|
43
|
+
| `event.data` in a `message` listener | `x.addEventListener('message', fn)`, `x.onmessage = fn`, `onmessage = fn`, including a `{ data }` parameter |
|
|
44
|
+
| Anthropic `tool_use` `.input` | `block.input` inside `if (block.type === 'tool_use')`, a `?:` / `&&` guard, `case 'tool_use':`, a `.find((b) => b.type === 'tool_use')` result, or a `.filter(...)` of that shape followed by `.map` / `.flatMap` / `.forEach` |
|
|
45
|
+
| `boundaries` option | any callee you list |
|
|
46
|
+
|
|
47
|
+
```ts bad reports=5
|
|
48
|
+
const prefs = JSON.parse(localStorage.getItem('prefs') ?? '{}') as Prefs;
|
|
49
|
+
const sort = new URLSearchParams(location.search).get('sort') as Sort;
|
|
50
|
+
window.addEventListener('message', (event) => handle(event.data as Message));
|
|
51
|
+
const args = JSON.parse(call.function.arguments) as WeatherArgs;
|
|
52
|
+
if (block.type === 'tool_use') run(block.input as WeatherArgs);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts good
|
|
56
|
+
const prefs = PrefsSchema.parse(JSON.parse(localStorage.getItem('prefs') ?? '{}'));
|
|
57
|
+
const sort = SortSchema.parse(new URLSearchParams(location.search).get('sort'));
|
|
58
|
+
window.addEventListener('message', (event) => handle(MessageSchema.parse(event.data)));
|
|
59
|
+
const args = WeatherArgsSchema.parse(JSON.parse(call.function.arguments));
|
|
60
|
+
if (block.type === 'tool_use') run(WeatherArgsSchema.parse(block.input));
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Through a `const`
|
|
64
|
+
|
|
65
|
+
A boundary value stored in a `const` and cast later in the same function is flagged too:
|
|
66
|
+
|
|
67
|
+
```ts bad
|
|
68
|
+
async function loadUser(res: Response) {
|
|
69
|
+
const raw = await res.json();
|
|
70
|
+
return raw as User;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```ts good
|
|
75
|
+
async function loadUser(res: Response) {
|
|
76
|
+
const raw = await res.json();
|
|
77
|
+
return UserSchema.parse(raw);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The binding is followed only when nothing could have checked the value first, so the rule gives the
|
|
82
|
+
benefit of the doubt whenever it cannot prove otherwise:
|
|
83
|
+
|
|
84
|
+
- Only a single `const name = <boundary>` is followed, never `let`, `var`, a destructuring pattern
|
|
85
|
+
or a parameter. Chains of such consts (`const body = await res.json(); const raw = body;`) are
|
|
86
|
+
followed.
|
|
87
|
+
- The cast must be in the same function as the declaration.
|
|
88
|
+
- Any read of the binding before the cast other than another `as` cast (a guard like
|
|
89
|
+
`isUser(raw)`, an `assertUser(raw)` call, an `'id' in raw` check, passing it to a function,
|
|
90
|
+
mutating a property) keeps the rule silent, as does any read from a nested function. Reads after
|
|
91
|
+
the cast do not excuse it.
|
|
92
|
+
|
|
93
|
+
```ts prose reason="shows what the rule deliberately leaves alone, not a fix for an example above"
|
|
94
|
+
async function loadUser(res: Response) {
|
|
95
|
+
const raw = await res.json();
|
|
96
|
+
assertUser(raw); // might have validated it: not flagged
|
|
97
|
+
return raw as User;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### What it leaves alone
|
|
102
|
+
|
|
103
|
+
- Casts to `unknown`, `any`, `const` or a primitive keyword (`as string | null`): the safe or
|
|
104
|
+
neutral forms.
|
|
105
|
+
- `satisfies`, which checks the value against the type instead of asserting it.
|
|
106
|
+
- A cast of the parse result (`UserSchema.parse(raw) as User`): the schema already checked it.
|
|
107
|
+
- Look-alikes that are not boundary reads: `cache.getItem(...)`, `map.get(...)`,
|
|
108
|
+
`headers.get(...)`, `.data` outside a `message` listener, `.input` without a `tool_use` guard.
|
|
109
|
+
- A boundary value that is neither cast directly nor bound by a followed `const` (a property of
|
|
110
|
+
an object, a function return value). That needs a type-aware setup.
|
|
38
111
|
|
|
39
112
|
## Options
|
|
40
113
|
|
|
41
|
-
|
|
114
|
+
```js
|
|
115
|
+
{
|
|
116
|
+
// Extra callees whose result is boundary data: a bare name or a dotted path.
|
|
117
|
+
// `readBody(event) as T` and `(await readBody(event)) as T` are then flagged.
|
|
118
|
+
boundaries: ['readBody', 'ipcRenderer.invoke'], // default []
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```ts bad options={"boundaries":["readBody"]}
|
|
123
|
+
const body = (await readBody(event)) as Body;
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```ts good
|
|
127
|
+
const body = BodySchema.parse(await readBody(event));
|
|
128
|
+
```
|
|
42
129
|
|
|
43
130
|
## When not to use it
|
|
44
131
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "ESLint rules for shared contract, config, error-handling, and money-precision conventions (zod schema naming, wire discriminants, no-direct-process-env, decimal money).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|