@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 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 `JSON.parse(...) as T` / `(await res.json()) as T`; parse boundary data at runtime. | | ๐Ÿ’ค |
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.type === import_utils8.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === import_utils8.AST_NODE_TYPES.Identifier && node.callee.object.name === "JSON" && node.callee.property.type === import_utils8.AST_NODE_TYPES.Identifier && node.callee.property.name === "parse";
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.type === import_utils8.AST_NODE_TYPES.CallExpression && call.arguments.length === 0 && call.callee.type === import_utils8.AST_NODE_TYPES.MemberExpression && !call.callee.computed && call.callee.property.type === import_utils8.AST_NODE_TYPES.Identifier && call.callee.property.name === "json";
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(...) as T` and `(await res.json()) as T`; use a zod/valibot 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
- const expr = node.expression;
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 optionSchema7 = {
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: [optionSchema7],
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 optionSchema8 = {
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: [optionSchema8],
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 unwrap(node) {
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 = unwrap(node);
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 = unwrap(node);
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 = unwrap(definition.node.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 = unwrap(node);
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 = unwrap(optionsArg);
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 : unwrap(definition.node.init);
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 = unwrap(object);
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 = unwrap(declarator.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 = unwrap(node);
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 = unwrap(property.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 = unwrap(keyNode);
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 optionSchema9 = {
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: [optionSchema9],
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 optionSchema10 = {
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: [optionSchema10],
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 optionSchema11 = {
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: [optionSchema11],
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.6.1";
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.type === AST_NODE_TYPES8.MemberExpression && !node.callee.computed && node.callee.object.type === AST_NODE_TYPES8.Identifier && node.callee.object.name === "JSON" && node.callee.property.type === AST_NODE_TYPES8.Identifier && node.callee.property.name === "parse";
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.type === AST_NODE_TYPES8.CallExpression && call.arguments.length === 0 && call.callee.type === AST_NODE_TYPES8.MemberExpression && !call.callee.computed && call.callee.property.type === AST_NODE_TYPES8.Identifier && call.callee.property.name === "json";
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(...) as T` and `(await res.json()) as T`; use a zod/valibot 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
- const expr = node.expression;
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 optionSchema7 = {
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: [optionSchema7],
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 optionSchema8 = {
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: [optionSchema8],
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 unwrap(node) {
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 = unwrap(node);
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 = unwrap(node);
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 = unwrap(definition.node.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 = unwrap(node);
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 = unwrap(optionsArg);
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 : unwrap(definition.node.init);
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 = unwrap(object);
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 = unwrap(declarator.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 = unwrap(node);
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 = unwrap(property.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 = unwrap(keyNode);
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 optionSchema9 = {
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: [optionSchema9],
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 optionSchema10 = {
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: [optionSchema10],
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 optionSchema11 = {
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: [optionSchema11],
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.6.1";
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 applied **directly** to a call site that is unmistakably a boundary read:
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
- Only these forms are flagged: `JSON.parse(...) as T` and `(await <expr>.json()) as T`, and only when
35
- the cast target is a shape claim (a named type or array โ€” `as User`, `as User[]`). Casts to `unknown`,
36
- `any`, or `const` are the safe/neutral forms and are never flagged. A boundary value stored in a
37
- variable and cast later is out of syntactic reach โ€” enforce that with a type-aware setup.
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
- None.
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.6.1",
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",