@sarj/eslint-plugin 15.2.0 → 15.3.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/dist/index.cjs CHANGED
@@ -3264,14 +3264,12 @@ var noJsonStringifyErrorDocumentation = {
3264
3264
  rationale: "Native Error details are non-enumerable, so generic JSON serialization discards diagnostic information.",
3265
3265
  remediation: "Serialize explicit error fields or use an error-aware serializer.",
3266
3266
  category: "correctness",
3267
- limitations: ["The rule uses local syntax and naming evidence rather than type information."],
3267
+ limitations: ["The rule uses local catch-binding and constructor provenance rather than type information."],
3268
3268
  examples: [
3269
3269
  { id: "explicit-error-message", title: "Serialize an enumerable error field", outcome: "no-match", files: [{ path: "src/report.ts", source: "try { f(); } catch (err) { JSON.stringify({ error: err.message }); }" }], focusPath: "src/report.ts", expectedCount: 0, public: true },
3270
3270
  { id: "stringified-error", title: "Do not stringify an Error object", outcome: "match", files: [{ path: "src/report.ts", source: "try { f(); } catch (err) { JSON.stringify({ error: err }); }" }], focusPath: "src/report.ts", expectedCount: 1, public: true }
3271
3271
  ]
3272
3272
  };
3273
- var ERROR_NAME_PATTERN = /^(e|err|error|ex|exc)$/i;
3274
- var ERROR_PROP_PATTERN = /^(cause|lastError|error|err|exception|originalError|innerError)$/i;
3275
3273
  var SAFE_STRING_PROPS = /* @__PURE__ */ new Set(["message", "stack", "name"]);
3276
3274
  var PAYLOAD_PROPS = /* @__PURE__ */ new Set([
3277
3275
  "data",
@@ -3299,6 +3297,21 @@ var BUILTIN_ERROR_CONSTRUCTORS = /* @__PURE__ */ new Set([
3299
3297
  "TypeError",
3300
3298
  "URIError"
3301
3299
  ]);
3300
+ function identifierIsProvenError(identifier, scope) {
3301
+ if (isCatchBinding(scope, identifier.name)) return true;
3302
+ let current = scope;
3303
+ while (current !== null && !current.set.has(identifier.name)) {
3304
+ current = current.upper;
3305
+ }
3306
+ const variable = current?.set.get(identifier.name);
3307
+ if (variable === void 0 || variable.defs.length !== 1) return false;
3308
+ const definition = variable.defs[0];
3309
+ if (definition?.type !== "Variable") return false;
3310
+ const initializer = definition.node.init;
3311
+ return initializer?.type === "NewExpression" && initializer.callee.type === "Identifier" && BUILTIN_ERROR_CONSTRUCTORS.has(initializer.callee.name) && variable.references.every(
3312
+ (reference) => !reference.isWrite() || reference.init === true
3313
+ );
3314
+ }
3302
3315
  function isCatchBinding(scope, name) {
3303
3316
  let current = scope;
3304
3317
  while (current) {
@@ -3314,22 +3327,6 @@ function isCatchBinding(scope, name) {
3314
3327
  }
3315
3328
  return false;
3316
3329
  }
3317
- function memberSuggestsError(member, scope) {
3318
- const propName2 = !member.computed && member.property.type === "Identifier" ? member.property.name : null;
3319
- if (propName2 !== null && ERROR_PROP_PATTERN.test(propName2)) {
3320
- return true;
3321
- }
3322
- const base = member.object;
3323
- const baseSuggestsError = base.type === "Identifier" && (ERROR_NAME_PATTERN.test(base.name) || isCatchBinding(scope, base.name));
3324
- if (baseSuggestsError) {
3325
- if (propName2 === null) {
3326
- return true;
3327
- }
3328
- const lowered = propName2.toLowerCase();
3329
- return !SAFE_STRING_PROPS.has(lowered) && !PAYLOAD_PROPS.has(lowered);
3330
- }
3331
- return false;
3332
- }
3333
3330
  function positiveErrorSubject(test) {
3334
3331
  return instanceofErrorSubject(test) ?? typeGuardSubject(test);
3335
3332
  }
@@ -3433,27 +3430,25 @@ function directLiteralValues(argument) {
3433
3430
  }
3434
3431
  function expressionSuggestsError(expression, scope) {
3435
3432
  if (expression.type === "Identifier") {
3436
- return ERROR_NAME_PATTERN.test(expression.name) || isCatchBinding(scope, expression.name);
3433
+ return identifierIsProvenError(expression, scope);
3434
+ }
3435
+ if (expression.type === "NewExpression" && expression.callee.type === "Identifier") {
3436
+ return BUILTIN_ERROR_CONSTRUCTORS.has(expression.callee.name);
3437
3437
  }
3438
3438
  return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3439
3439
  }
3440
- function nestedExpressionSuggestsError(expression, scope) {
3441
- if (expression.type === "Identifier") {
3442
- if (isCatchBinding(scope, expression.name)) return true;
3443
- let current = scope;
3444
- while (current !== null && !current.set.has(expression.name)) {
3445
- current = current.upper;
3446
- }
3447
- const variable = current?.set.get(expression.name);
3448
- if (variable === void 0 || variable.defs.length !== 1) return false;
3449
- const definition = variable.defs[0];
3450
- if (definition?.type !== "Variable") return false;
3451
- const initializer = definition.node.init;
3452
- return initializer?.type === "NewExpression" && initializer.callee.type === "Identifier" && BUILTIN_ERROR_CONSTRUCTORS.has(initializer.callee.name) && variable.references.every(
3453
- (reference) => !reference.isWrite() || reference.init === true
3454
- );
3440
+ function memberSuggestsError(member, scope) {
3441
+ const propName2 = !member.computed && member.property.type === "Identifier" ? member.property.name : null;
3442
+ const base = member.object;
3443
+ const baseSuggestsError = base.type === "Identifier" && identifierIsProvenError(base, scope);
3444
+ if (baseSuggestsError) {
3445
+ if (propName2 === null) {
3446
+ return true;
3447
+ }
3448
+ const lowered = propName2.toLowerCase();
3449
+ return !SAFE_STRING_PROPS.has(lowered) && !PAYLOAD_PROPS.has(lowered);
3455
3450
  }
3456
- return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3451
+ return false;
3457
3452
  }
3458
3453
  var no_json_stringify_error_default = createRule({
3459
3454
  name: "no-json-stringify-error",
@@ -3480,9 +3475,8 @@ var no_json_stringify_error_default = createRule({
3480
3475
  return;
3481
3476
  }
3482
3477
  const scope = context.sourceCode.getScope(firstArg);
3483
- const isNestedLiteral = firstArg.type === "ObjectExpression" || firstArg.type === "ArrayExpression";
3484
3478
  const unsafeValue = directLiteralValues(firstArg).find(
3485
- (value) => (isNestedLiteral ? nestedExpressionSuggestsError(value, scope) : expressionSuggestsError(value, scope)) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3479
+ (value) => expressionSuggestsError(value, scope) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3486
3480
  );
3487
3481
  if (unsafeValue === void 0) {
3488
3482
  return;
@@ -4941,6 +4935,7 @@ var ANALYTICS_SEGMENTS2 = /* @__PURE__ */ new Set([
4941
4935
  ]);
4942
4936
  var SERVER_ACTION_SKIP_FILE_RE = /(?:\.test\.[jt]sx?$|\.spec\.[jt]sx?$|-(?:test|spec)\.[jt]sx?$|\/tests?\/|\/__tests__\/|\/__testfixtures__\/|\/scripts?\/|\/app\/api\/.*\/route\.[jt]sx?$|\/pages\/api\/)/;
4943
4937
  var NON_REACT_FRAMEWORK_RE = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
4938
+ var NEXT_MODULE_PATH_RE = /(?:^|[/\\])(?:app|pages)[/\\]/u;
4944
4939
  function isGlobalFetchCall(node, resolvesToGlobal) {
4945
4940
  const callee = node.callee;
4946
4941
  if (callee.type === "Identifier") {
@@ -5056,6 +5051,13 @@ var no_raw_fetch_outside_clients_default = createRule({
5056
5051
  const nonReactFramework = context.sourceCode.ast.body.some(
5057
5052
  (statement) => statement.type === import_utils25.AST_NODE_TYPES.ImportDeclaration && typeof statement.source.value === "string" && NON_REACT_FRAMEWORK_RE.test(statement.source.value)
5058
5053
  );
5054
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
5055
+ (statement) => statement.type === import_utils25.AST_NODE_TYPES.ExpressionStatement && statement.expression.type === import_utils25.AST_NODE_TYPES.Literal && statement.expression.value === "use client"
5056
+ );
5057
+ const hasNextImport = context.sourceCode.ast.body.some(
5058
+ (statement) => statement.type === import_utils25.AST_NODE_TYPES.ImportDeclaration && typeof statement.source.value === "string" && (statement.source.value === "next" || statement.source.value.startsWith("next/"))
5059
+ );
5060
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE.test(filename);
5059
5061
  function resolvesToGlobal(identifier) {
5060
5062
  const variable = import_utils25.ASTUtils.findVariable(
5061
5063
  context.sourceCode.getScope(identifier),
@@ -5116,7 +5118,7 @@ var no_raw_fetch_outside_clients_default = createRule({
5116
5118
  return resolved?.type === import_utils25.AST_NODE_TYPES.LogicalExpression && resolved.operator === "||" && (isMutationMethod2(resolved.left) || isMutationMethod2(resolved.right));
5117
5119
  }
5118
5120
  function serverActionOwns(node) {
5119
- if (node.callee.type !== import_utils25.AST_NODE_TYPES.Identifier || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5121
+ if (node.callee.type !== import_utils25.AST_NODE_TYPES.Identifier || !hasNextEvidence || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5120
5122
  return false;
5121
5123
  }
5122
5124
  const url = node.arguments[0];
@@ -6335,6 +6337,20 @@ var SAFE_PARSE_CONSTRUCTORS = /* @__PURE__ */ new Set([
6335
6337
  function isBodyDecodeNode(node) {
6336
6338
  return node.type === import_utils32.AST_NODE_TYPES.CallExpression && node.callee.type === import_utils32.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.property.type === import_utils32.AST_NODE_TYPES.Identifier && BODY_DECODE_METHODS.has(node.callee.property.name);
6337
6339
  }
6340
+ function isSafeParseSupportCall(node) {
6341
+ const callee = node.callee;
6342
+ if (callee.type !== import_utils32.AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== import_utils32.AST_NODE_TYPES.Identifier) {
6343
+ return false;
6344
+ }
6345
+ if (callee.property.name === "isArray" && callee.object.type === import_utils32.AST_NODE_TYPES.Identifier && callee.object.name === "Array") {
6346
+ return true;
6347
+ }
6348
+ if (callee.property.name !== "getItem") return false;
6349
+ if (callee.object.type === import_utils32.AST_NODE_TYPES.Identifier && (callee.object.name === "localStorage" || callee.object.name === "sessionStorage")) {
6350
+ return true;
6351
+ }
6352
+ return callee.object.type === import_utils32.AST_NODE_TYPES.MemberExpression && !callee.object.computed && callee.object.object.type === import_utils32.AST_NODE_TYPES.Identifier && (callee.object.object.name === "window" || callee.object.object.name === "globalThis") && callee.object.property.type === import_utils32.AST_NODE_TYPES.Identifier && (callee.object.property.name === "localStorage" || callee.object.property.name === "sessionStorage");
6353
+ }
6338
6354
  var BODY_DECODE_METHODS = /* @__PURE__ */ new Set([
6339
6355
  "json",
6340
6356
  "text",
@@ -6403,6 +6419,7 @@ function tryReturnsSafeParse(catchNode) {
6403
6419
  if (current.type === import_utils32.AST_NODE_TYPES.CallExpression || current.type === import_utils32.AST_NODE_TYPES.NewExpression) {
6404
6420
  if (isParseShapedNode(current) || isBodyDecodeNode(current)) {
6405
6421
  sawSafeParse = true;
6422
+ } else if (current.type === import_utils32.AST_NODE_TYPES.CallExpression && isSafeParseSupportCall(current)) {
6406
6423
  } else {
6407
6424
  sawUnsafeOperation = true;
6408
6425
  return;
@@ -10926,7 +10943,70 @@ var bindingValidationPolarity = (test, bindingName) => {
10926
10943
  }
10927
10944
  return test.type === import_utils57.AST_NODE_TYPES.CallExpression && test.arguments.length === 1 && test.arguments[0]?.type === import_utils57.AST_NODE_TYPES.Identifier && test.arguments[0].name === bindingName && test.callee.type === import_utils57.AST_NODE_TYPES.MemberExpression && !test.callee.computed && test.callee.object.type === import_utils57.AST_NODE_TYPES.Identifier && test.callee.object.name === "Array" && test.callee.property.type === import_utils57.AST_NODE_TYPES.Identifier && test.callee.property.name === "isArray" ? "valid-when-true" : null;
10928
10945
  };
10946
+ var plainMemberAccess = (node) => node.type === import_utils57.AST_NODE_TYPES.MemberExpression && !node.computed && node.object.type === import_utils57.AST_NODE_TYPES.Identifier && node.property.type === import_utils57.AST_NODE_TYPES.Identifier ? { object: node.object.name, property: node.property.name } : null;
10947
+ var isSamePlainMember = (node, access) => {
10948
+ const candidate = plainMemberAccess(node);
10949
+ return candidate !== null && candidate.object === access.object && candidate.property === access.property;
10950
+ };
10929
10951
  var nodeWithin2 = (node, container) => node.range[0] >= container.range[0] && node.range[1] <= container.range[1];
10952
+ var isUseWithinValidatedBranch = (node, bindingName) => {
10953
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10954
+ if (current.type === import_utils57.AST_NODE_TYPES.ConditionalExpression) {
10955
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10956
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10957
+ return true;
10958
+ }
10959
+ }
10960
+ if (current.type === import_utils57.AST_NODE_TYPES.IfStatement) {
10961
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10962
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10963
+ return true;
10964
+ }
10965
+ }
10966
+ if (current.type === import_utils57.AST_NODE_TYPES.FunctionDeclaration || current.type === import_utils57.AST_NODE_TYPES.FunctionExpression || current.type === import_utils57.AST_NODE_TYPES.ArrowFunctionExpression) {
10967
+ return false;
10968
+ }
10969
+ }
10970
+ return false;
10971
+ };
10972
+ var isMemberUseWithinValidatedBranch = (node, access) => {
10973
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10974
+ if (current.type === import_utils57.AST_NODE_TYPES.ConditionalExpression) {
10975
+ const polarity = memberValidationPolarity(current.test, access);
10976
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10977
+ return true;
10978
+ }
10979
+ }
10980
+ if (current.type === import_utils57.AST_NODE_TYPES.IfStatement) {
10981
+ const polarity = memberValidationPolarity(current.test, access);
10982
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10983
+ return true;
10984
+ }
10985
+ }
10986
+ if (current.type === import_utils57.AST_NODE_TYPES.FunctionDeclaration || current.type === import_utils57.AST_NODE_TYPES.FunctionExpression || current.type === import_utils57.AST_NODE_TYPES.ArrowFunctionExpression) {
10987
+ return false;
10988
+ }
10989
+ }
10990
+ return false;
10991
+ };
10992
+ var memberValidationPolarity = (test, access) => {
10993
+ if (test.type === import_utils57.AST_NODE_TYPES.UnaryExpression && test.operator === "!") {
10994
+ const inner = memberValidationPolarity(test.argument, access);
10995
+ return inner === "valid-when-true" ? "valid-when-false" : inner === "valid-when-false" ? "valid-when-true" : null;
10996
+ }
10997
+ if (test.type === import_utils57.AST_NODE_TYPES.BinaryExpression) {
10998
+ const isMatchingTypeof = (node) => node.type === import_utils57.AST_NODE_TYPES.UnaryExpression && node.operator === "typeof" && isSamePlainMember(node.argument, access);
10999
+ const isPrimitiveType = (node) => node.type === import_utils57.AST_NODE_TYPES.Literal && typeof node.value === "string" && PRIMITIVE_TYPEOF_RESULTS.has(node.value);
11000
+ if (!(isMatchingTypeof(test.left) && isPrimitiveType(test.right) || isMatchingTypeof(test.right) && isPrimitiveType(test.left))) {
11001
+ return null;
11002
+ }
11003
+ if (test.operator === "===" || test.operator === "==") {
11004
+ return "valid-when-true";
11005
+ }
11006
+ return test.operator === "!==" || test.operator === "!=" ? "valid-when-false" : null;
11007
+ }
11008
+ return test.type === import_utils57.AST_NODE_TYPES.CallExpression && test.arguments.length === 1 && test.arguments[0] !== void 0 && test.arguments[0].type !== import_utils57.AST_NODE_TYPES.SpreadElement && isSamePlainMember(test.arguments[0], access) && test.callee.type === import_utils57.AST_NODE_TYPES.MemberExpression && !test.callee.computed && test.callee.object.type === import_utils57.AST_NODE_TYPES.Identifier && test.callee.object.name === "Array" && test.callee.property.type === import_utils57.AST_NODE_TYPES.Identifier && test.callee.property.name === "isArray" ? "valid-when-true" : null;
11009
+ };
10930
11010
  var isFullyValidatedExtractedBinding = (member, source, context) => {
10931
11011
  const isValidationReference = (identifier) => {
10932
11012
  for (let current = identifier.parent; current !== void 0 && current !== null; current = current.parent) {
@@ -11196,7 +11276,14 @@ var prefer_schema_for_api_payload_default = createRule({
11196
11276
  return;
11197
11277
  }
11198
11278
  const variable = obj?.type === import_utils57.AST_NODE_TYPES.Identifier ? unvalidatedVariableRef(obj, scope, unvalidatedVariables) : null;
11199
- if (variable !== null) {
11279
+ if (variable !== null && obj?.type === import_utils57.AST_NODE_TYPES.Identifier) {
11280
+ if (isUseWithinValidatedBranch(node, obj.name)) {
11281
+ return;
11282
+ }
11283
+ const access = plainMemberAccess(node);
11284
+ if (access !== null && isMemberUseWithinValidatedBranch(node, access)) {
11285
+ return;
11286
+ }
11200
11287
  if (isFullyValidatedExtractedBinding(node, variable, context)) {
11201
11288
  return;
11202
11289
  }
@@ -11648,16 +11735,17 @@ var preferServerActionsDocumentation = {
11648
11735
  rationale: "Server Actions preserve typed application calls and avoid an internal JSON request-response boundary.",
11649
11736
  remediation: "Move the mutation into a Server Action and invoke that action from the React client.",
11650
11737
  category: "architecture",
11651
- limitations: ["Only statically recognizable /api/ mutations in applicable React modules are reported."],
11738
+ limitations: ["Only statically recognizable /api/ mutations in modules with positive Next.js evidence are reported: an explicit next import, or an app/pages path with a top-level use-client directive."],
11652
11739
  examples: [
11653
11740
  { id: "server-action-call", title: "Call a Server Action", outcome: "no-match", files: [{ path: "app/tasks/page.tsx", source: "import { createTask } from './actions'; await createTask(input);" }], focusPath: "app/tasks/page.tsx", expectedCount: 0, public: true },
11654
- { id: "api-mutation", title: "Do not mutate through an API route", outcome: "match", files: [{ path: "app/tasks/page.tsx", source: "await fetch('/api/tasks', { method: 'POST', body });" }], focusPath: "app/tasks/page.tsx", expectedCount: 1, public: true }
11741
+ { id: "api-mutation", title: "Do not mutate through an API route", outcome: "match", files: [{ path: "app/tasks/page.tsx", source: "'use client'; await fetch('/api/tasks', { method: 'POST', body });" }], focusPath: "app/tasks/page.tsx", expectedCount: 1, public: true }
11655
11742
  ]
11656
11743
  };
11657
11744
  var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE", "PATCH"]);
11658
11745
  var AXIOS_MUTATION_METHODS = /* @__PURE__ */ new Set(["post", "put", "delete", "patch"]);
11659
11746
  var SKIP_FILE_REGEX = /(?:\.test\.[jt]sx?$|\.spec\.[jt]sx?$|-(?:test|spec)\.[jt]sx?$|\/tests?\/|\/__tests__\/|\/__testfixtures__\/|\/scripts?\/|\/app\/api\/.*\/route\.[jt]sx?$|\/pages\/api\/)/;
11660
11747
  var NON_REACT_FRAMEWORK_RE2 = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
11748
+ var NEXT_MODULE_PATH_RE2 = /(?:^|[/\\])(?:app|pages)[/\\]/u;
11661
11749
  function getScope(context, node) {
11662
11750
  return context.sourceCode.getScope(node);
11663
11751
  }
@@ -11771,6 +11859,16 @@ var prefer_server_actions_default = createRule({
11771
11859
  const isNonReactFramework = context.sourceCode.ast.body.some(
11772
11860
  (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && NON_REACT_FRAMEWORK_RE2.test(node.source.value)
11773
11861
  );
11862
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
11863
+ (node) => node.type === "ExpressionStatement" && node.expression.type === "Literal" && node.expression.value === "use client"
11864
+ );
11865
+ const hasNextImport = context.sourceCode.ast.body.some(
11866
+ (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && (node.source.value === "next" || node.source.value.startsWith("next/"))
11867
+ );
11868
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE2.test(filename);
11869
+ if (!hasNextEvidence) {
11870
+ return {};
11871
+ }
11774
11872
  return {
11775
11873
  CallExpression(node) {
11776
11874
  if (isNonReactFramework) return;
@@ -14338,7 +14436,7 @@ var rules = {
14338
14436
  };
14339
14437
  var meta = {
14340
14438
  name: "@sarj/eslint-plugin",
14341
- version: "15.2.0"
14439
+ version: "15.3.0"
14342
14440
  };
14343
14441
  var applicationOnlyRules = [
14344
14442
  "no-restricted-library-load",
package/dist/index.d.cts CHANGED
@@ -414,7 +414,7 @@ type FlatPreset = {
414
414
  declare const plugin: {
415
415
  readonly meta: {
416
416
  readonly name: "@sarj/eslint-plugin";
417
- readonly version: "15.2.0";
417
+ readonly version: "15.3.0";
418
418
  };
419
419
  readonly rules: {
420
420
  readonly "duplicate-test-body": DocumentedRule<readonly [], "duplicateTestBody">;
package/dist/index.d.ts CHANGED
@@ -414,7 +414,7 @@ type FlatPreset = {
414
414
  declare const plugin: {
415
415
  readonly meta: {
416
416
  readonly name: "@sarj/eslint-plugin";
417
- readonly version: "15.2.0";
417
+ readonly version: "15.3.0";
418
418
  };
419
419
  readonly rules: {
420
420
  readonly "duplicate-test-body": DocumentedRule<readonly [], "duplicateTestBody">;
package/dist/index.js CHANGED
@@ -3224,14 +3224,12 @@ var noJsonStringifyErrorDocumentation = {
3224
3224
  rationale: "Native Error details are non-enumerable, so generic JSON serialization discards diagnostic information.",
3225
3225
  remediation: "Serialize explicit error fields or use an error-aware serializer.",
3226
3226
  category: "correctness",
3227
- limitations: ["The rule uses local syntax and naming evidence rather than type information."],
3227
+ limitations: ["The rule uses local catch-binding and constructor provenance rather than type information."],
3228
3228
  examples: [
3229
3229
  { id: "explicit-error-message", title: "Serialize an enumerable error field", outcome: "no-match", files: [{ path: "src/report.ts", source: "try { f(); } catch (err) { JSON.stringify({ error: err.message }); }" }], focusPath: "src/report.ts", expectedCount: 0, public: true },
3230
3230
  { id: "stringified-error", title: "Do not stringify an Error object", outcome: "match", files: [{ path: "src/report.ts", source: "try { f(); } catch (err) { JSON.stringify({ error: err }); }" }], focusPath: "src/report.ts", expectedCount: 1, public: true }
3231
3231
  ]
3232
3232
  };
3233
- var ERROR_NAME_PATTERN = /^(e|err|error|ex|exc)$/i;
3234
- var ERROR_PROP_PATTERN = /^(cause|lastError|error|err|exception|originalError|innerError)$/i;
3235
3233
  var SAFE_STRING_PROPS = /* @__PURE__ */ new Set(["message", "stack", "name"]);
3236
3234
  var PAYLOAD_PROPS = /* @__PURE__ */ new Set([
3237
3235
  "data",
@@ -3259,6 +3257,21 @@ var BUILTIN_ERROR_CONSTRUCTORS = /* @__PURE__ */ new Set([
3259
3257
  "TypeError",
3260
3258
  "URIError"
3261
3259
  ]);
3260
+ function identifierIsProvenError(identifier, scope) {
3261
+ if (isCatchBinding(scope, identifier.name)) return true;
3262
+ let current = scope;
3263
+ while (current !== null && !current.set.has(identifier.name)) {
3264
+ current = current.upper;
3265
+ }
3266
+ const variable = current?.set.get(identifier.name);
3267
+ if (variable === void 0 || variable.defs.length !== 1) return false;
3268
+ const definition = variable.defs[0];
3269
+ if (definition?.type !== "Variable") return false;
3270
+ const initializer = definition.node.init;
3271
+ return initializer?.type === "NewExpression" && initializer.callee.type === "Identifier" && BUILTIN_ERROR_CONSTRUCTORS.has(initializer.callee.name) && variable.references.every(
3272
+ (reference) => !reference.isWrite() || reference.init === true
3273
+ );
3274
+ }
3262
3275
  function isCatchBinding(scope, name) {
3263
3276
  let current = scope;
3264
3277
  while (current) {
@@ -3274,22 +3287,6 @@ function isCatchBinding(scope, name) {
3274
3287
  }
3275
3288
  return false;
3276
3289
  }
3277
- function memberSuggestsError(member, scope) {
3278
- const propName2 = !member.computed && member.property.type === "Identifier" ? member.property.name : null;
3279
- if (propName2 !== null && ERROR_PROP_PATTERN.test(propName2)) {
3280
- return true;
3281
- }
3282
- const base = member.object;
3283
- const baseSuggestsError = base.type === "Identifier" && (ERROR_NAME_PATTERN.test(base.name) || isCatchBinding(scope, base.name));
3284
- if (baseSuggestsError) {
3285
- if (propName2 === null) {
3286
- return true;
3287
- }
3288
- const lowered = propName2.toLowerCase();
3289
- return !SAFE_STRING_PROPS.has(lowered) && !PAYLOAD_PROPS.has(lowered);
3290
- }
3291
- return false;
3292
- }
3293
3290
  function positiveErrorSubject(test) {
3294
3291
  return instanceofErrorSubject(test) ?? typeGuardSubject(test);
3295
3292
  }
@@ -3393,27 +3390,25 @@ function directLiteralValues(argument) {
3393
3390
  }
3394
3391
  function expressionSuggestsError(expression, scope) {
3395
3392
  if (expression.type === "Identifier") {
3396
- return ERROR_NAME_PATTERN.test(expression.name) || isCatchBinding(scope, expression.name);
3393
+ return identifierIsProvenError(expression, scope);
3394
+ }
3395
+ if (expression.type === "NewExpression" && expression.callee.type === "Identifier") {
3396
+ return BUILTIN_ERROR_CONSTRUCTORS.has(expression.callee.name);
3397
3397
  }
3398
3398
  return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3399
3399
  }
3400
- function nestedExpressionSuggestsError(expression, scope) {
3401
- if (expression.type === "Identifier") {
3402
- if (isCatchBinding(scope, expression.name)) return true;
3403
- let current = scope;
3404
- while (current !== null && !current.set.has(expression.name)) {
3405
- current = current.upper;
3406
- }
3407
- const variable = current?.set.get(expression.name);
3408
- if (variable === void 0 || variable.defs.length !== 1) return false;
3409
- const definition = variable.defs[0];
3410
- if (definition?.type !== "Variable") return false;
3411
- const initializer = definition.node.init;
3412
- return initializer?.type === "NewExpression" && initializer.callee.type === "Identifier" && BUILTIN_ERROR_CONSTRUCTORS.has(initializer.callee.name) && variable.references.every(
3413
- (reference) => !reference.isWrite() || reference.init === true
3414
- );
3400
+ function memberSuggestsError(member, scope) {
3401
+ const propName2 = !member.computed && member.property.type === "Identifier" ? member.property.name : null;
3402
+ const base = member.object;
3403
+ const baseSuggestsError = base.type === "Identifier" && identifierIsProvenError(base, scope);
3404
+ if (baseSuggestsError) {
3405
+ if (propName2 === null) {
3406
+ return true;
3407
+ }
3408
+ const lowered = propName2.toLowerCase();
3409
+ return !SAFE_STRING_PROPS.has(lowered) && !PAYLOAD_PROPS.has(lowered);
3415
3410
  }
3416
- return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3411
+ return false;
3417
3412
  }
3418
3413
  var no_json_stringify_error_default = createRule({
3419
3414
  name: "no-json-stringify-error",
@@ -3440,9 +3435,8 @@ var no_json_stringify_error_default = createRule({
3440
3435
  return;
3441
3436
  }
3442
3437
  const scope = context.sourceCode.getScope(firstArg);
3443
- const isNestedLiteral = firstArg.type === "ObjectExpression" || firstArg.type === "ArrayExpression";
3444
3438
  const unsafeValue = directLiteralValues(firstArg).find(
3445
- (value) => (isNestedLiteral ? nestedExpressionSuggestsError(value, scope) : expressionSuggestsError(value, scope)) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3439
+ (value) => expressionSuggestsError(value, scope) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3446
3440
  );
3447
3441
  if (unsafeValue === void 0) {
3448
3442
  return;
@@ -4903,6 +4897,7 @@ var ANALYTICS_SEGMENTS2 = /* @__PURE__ */ new Set([
4903
4897
  ]);
4904
4898
  var SERVER_ACTION_SKIP_FILE_RE = /(?:\.test\.[jt]sx?$|\.spec\.[jt]sx?$|-(?:test|spec)\.[jt]sx?$|\/tests?\/|\/__tests__\/|\/__testfixtures__\/|\/scripts?\/|\/app\/api\/.*\/route\.[jt]sx?$|\/pages\/api\/)/;
4905
4899
  var NON_REACT_FRAMEWORK_RE = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
4900
+ var NEXT_MODULE_PATH_RE = /(?:^|[/\\])(?:app|pages)[/\\]/u;
4906
4901
  function isGlobalFetchCall(node, resolvesToGlobal) {
4907
4902
  const callee = node.callee;
4908
4903
  if (callee.type === "Identifier") {
@@ -5018,6 +5013,13 @@ var no_raw_fetch_outside_clients_default = createRule({
5018
5013
  const nonReactFramework = context.sourceCode.ast.body.some(
5019
5014
  (statement) => statement.type === AST_NODE_TYPES18.ImportDeclaration && typeof statement.source.value === "string" && NON_REACT_FRAMEWORK_RE.test(statement.source.value)
5020
5015
  );
5016
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
5017
+ (statement) => statement.type === AST_NODE_TYPES18.ExpressionStatement && statement.expression.type === AST_NODE_TYPES18.Literal && statement.expression.value === "use client"
5018
+ );
5019
+ const hasNextImport = context.sourceCode.ast.body.some(
5020
+ (statement) => statement.type === AST_NODE_TYPES18.ImportDeclaration && typeof statement.source.value === "string" && (statement.source.value === "next" || statement.source.value.startsWith("next/"))
5021
+ );
5022
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE.test(filename);
5021
5023
  function resolvesToGlobal(identifier) {
5022
5024
  const variable = ASTUtils5.findVariable(
5023
5025
  context.sourceCode.getScope(identifier),
@@ -5078,7 +5080,7 @@ var no_raw_fetch_outside_clients_default = createRule({
5078
5080
  return resolved?.type === AST_NODE_TYPES18.LogicalExpression && resolved.operator === "||" && (isMutationMethod2(resolved.left) || isMutationMethod2(resolved.right));
5079
5081
  }
5080
5082
  function serverActionOwns(node) {
5081
- if (node.callee.type !== AST_NODE_TYPES18.Identifier || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5083
+ if (node.callee.type !== AST_NODE_TYPES18.Identifier || !hasNextEvidence || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5082
5084
  return false;
5083
5085
  }
5084
5086
  const url = node.arguments[0];
@@ -6297,6 +6299,20 @@ var SAFE_PARSE_CONSTRUCTORS = /* @__PURE__ */ new Set([
6297
6299
  function isBodyDecodeNode(node) {
6298
6300
  return node.type === AST_NODE_TYPES23.CallExpression && node.callee.type === AST_NODE_TYPES23.MemberExpression && !node.callee.computed && node.callee.property.type === AST_NODE_TYPES23.Identifier && BODY_DECODE_METHODS.has(node.callee.property.name);
6299
6301
  }
6302
+ function isSafeParseSupportCall(node) {
6303
+ const callee = node.callee;
6304
+ if (callee.type !== AST_NODE_TYPES23.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES23.Identifier) {
6305
+ return false;
6306
+ }
6307
+ if (callee.property.name === "isArray" && callee.object.type === AST_NODE_TYPES23.Identifier && callee.object.name === "Array") {
6308
+ return true;
6309
+ }
6310
+ if (callee.property.name !== "getItem") return false;
6311
+ if (callee.object.type === AST_NODE_TYPES23.Identifier && (callee.object.name === "localStorage" || callee.object.name === "sessionStorage")) {
6312
+ return true;
6313
+ }
6314
+ return callee.object.type === AST_NODE_TYPES23.MemberExpression && !callee.object.computed && callee.object.object.type === AST_NODE_TYPES23.Identifier && (callee.object.object.name === "window" || callee.object.object.name === "globalThis") && callee.object.property.type === AST_NODE_TYPES23.Identifier && (callee.object.property.name === "localStorage" || callee.object.property.name === "sessionStorage");
6315
+ }
6300
6316
  var BODY_DECODE_METHODS = /* @__PURE__ */ new Set([
6301
6317
  "json",
6302
6318
  "text",
@@ -6365,6 +6381,7 @@ function tryReturnsSafeParse(catchNode) {
6365
6381
  if (current.type === AST_NODE_TYPES23.CallExpression || current.type === AST_NODE_TYPES23.NewExpression) {
6366
6382
  if (isParseShapedNode(current) || isBodyDecodeNode(current)) {
6367
6383
  sawSafeParse = true;
6384
+ } else if (current.type === AST_NODE_TYPES23.CallExpression && isSafeParseSupportCall(current)) {
6368
6385
  } else {
6369
6386
  sawUnsafeOperation = true;
6370
6387
  return;
@@ -10895,7 +10912,70 @@ var bindingValidationPolarity = (test, bindingName) => {
10895
10912
  }
10896
10913
  return test.type === AST_NODE_TYPES45.CallExpression && test.arguments.length === 1 && test.arguments[0]?.type === AST_NODE_TYPES45.Identifier && test.arguments[0].name === bindingName && test.callee.type === AST_NODE_TYPES45.MemberExpression && !test.callee.computed && test.callee.object.type === AST_NODE_TYPES45.Identifier && test.callee.object.name === "Array" && test.callee.property.type === AST_NODE_TYPES45.Identifier && test.callee.property.name === "isArray" ? "valid-when-true" : null;
10897
10914
  };
10915
+ var plainMemberAccess = (node) => node.type === AST_NODE_TYPES45.MemberExpression && !node.computed && node.object.type === AST_NODE_TYPES45.Identifier && node.property.type === AST_NODE_TYPES45.Identifier ? { object: node.object.name, property: node.property.name } : null;
10916
+ var isSamePlainMember = (node, access) => {
10917
+ const candidate = plainMemberAccess(node);
10918
+ return candidate !== null && candidate.object === access.object && candidate.property === access.property;
10919
+ };
10898
10920
  var nodeWithin2 = (node, container) => node.range[0] >= container.range[0] && node.range[1] <= container.range[1];
10921
+ var isUseWithinValidatedBranch = (node, bindingName) => {
10922
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10923
+ if (current.type === AST_NODE_TYPES45.ConditionalExpression) {
10924
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10925
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10926
+ return true;
10927
+ }
10928
+ }
10929
+ if (current.type === AST_NODE_TYPES45.IfStatement) {
10930
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10931
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10932
+ return true;
10933
+ }
10934
+ }
10935
+ if (current.type === AST_NODE_TYPES45.FunctionDeclaration || current.type === AST_NODE_TYPES45.FunctionExpression || current.type === AST_NODE_TYPES45.ArrowFunctionExpression) {
10936
+ return false;
10937
+ }
10938
+ }
10939
+ return false;
10940
+ };
10941
+ var isMemberUseWithinValidatedBranch = (node, access) => {
10942
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10943
+ if (current.type === AST_NODE_TYPES45.ConditionalExpression) {
10944
+ const polarity = memberValidationPolarity(current.test, access);
10945
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10946
+ return true;
10947
+ }
10948
+ }
10949
+ if (current.type === AST_NODE_TYPES45.IfStatement) {
10950
+ const polarity = memberValidationPolarity(current.test, access);
10951
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10952
+ return true;
10953
+ }
10954
+ }
10955
+ if (current.type === AST_NODE_TYPES45.FunctionDeclaration || current.type === AST_NODE_TYPES45.FunctionExpression || current.type === AST_NODE_TYPES45.ArrowFunctionExpression) {
10956
+ return false;
10957
+ }
10958
+ }
10959
+ return false;
10960
+ };
10961
+ var memberValidationPolarity = (test, access) => {
10962
+ if (test.type === AST_NODE_TYPES45.UnaryExpression && test.operator === "!") {
10963
+ const inner = memberValidationPolarity(test.argument, access);
10964
+ return inner === "valid-when-true" ? "valid-when-false" : inner === "valid-when-false" ? "valid-when-true" : null;
10965
+ }
10966
+ if (test.type === AST_NODE_TYPES45.BinaryExpression) {
10967
+ const isMatchingTypeof = (node) => node.type === AST_NODE_TYPES45.UnaryExpression && node.operator === "typeof" && isSamePlainMember(node.argument, access);
10968
+ const isPrimitiveType = (node) => node.type === AST_NODE_TYPES45.Literal && typeof node.value === "string" && PRIMITIVE_TYPEOF_RESULTS.has(node.value);
10969
+ if (!(isMatchingTypeof(test.left) && isPrimitiveType(test.right) || isMatchingTypeof(test.right) && isPrimitiveType(test.left))) {
10970
+ return null;
10971
+ }
10972
+ if (test.operator === "===" || test.operator === "==") {
10973
+ return "valid-when-true";
10974
+ }
10975
+ return test.operator === "!==" || test.operator === "!=" ? "valid-when-false" : null;
10976
+ }
10977
+ return test.type === AST_NODE_TYPES45.CallExpression && test.arguments.length === 1 && test.arguments[0] !== void 0 && test.arguments[0].type !== AST_NODE_TYPES45.SpreadElement && isSamePlainMember(test.arguments[0], access) && test.callee.type === AST_NODE_TYPES45.MemberExpression && !test.callee.computed && test.callee.object.type === AST_NODE_TYPES45.Identifier && test.callee.object.name === "Array" && test.callee.property.type === AST_NODE_TYPES45.Identifier && test.callee.property.name === "isArray" ? "valid-when-true" : null;
10978
+ };
10899
10979
  var isFullyValidatedExtractedBinding = (member, source, context) => {
10900
10980
  const isValidationReference = (identifier) => {
10901
10981
  for (let current = identifier.parent; current !== void 0 && current !== null; current = current.parent) {
@@ -11165,7 +11245,14 @@ var prefer_schema_for_api_payload_default = createRule({
11165
11245
  return;
11166
11246
  }
11167
11247
  const variable = obj?.type === AST_NODE_TYPES45.Identifier ? unvalidatedVariableRef(obj, scope, unvalidatedVariables) : null;
11168
- if (variable !== null) {
11248
+ if (variable !== null && obj?.type === AST_NODE_TYPES45.Identifier) {
11249
+ if (isUseWithinValidatedBranch(node, obj.name)) {
11250
+ return;
11251
+ }
11252
+ const access = plainMemberAccess(node);
11253
+ if (access !== null && isMemberUseWithinValidatedBranch(node, access)) {
11254
+ return;
11255
+ }
11169
11256
  if (isFullyValidatedExtractedBinding(node, variable, context)) {
11170
11257
  return;
11171
11258
  }
@@ -11617,16 +11704,17 @@ var preferServerActionsDocumentation = {
11617
11704
  rationale: "Server Actions preserve typed application calls and avoid an internal JSON request-response boundary.",
11618
11705
  remediation: "Move the mutation into a Server Action and invoke that action from the React client.",
11619
11706
  category: "architecture",
11620
- limitations: ["Only statically recognizable /api/ mutations in applicable React modules are reported."],
11707
+ limitations: ["Only statically recognizable /api/ mutations in modules with positive Next.js evidence are reported: an explicit next import, or an app/pages path with a top-level use-client directive."],
11621
11708
  examples: [
11622
11709
  { id: "server-action-call", title: "Call a Server Action", outcome: "no-match", files: [{ path: "app/tasks/page.tsx", source: "import { createTask } from './actions'; await createTask(input);" }], focusPath: "app/tasks/page.tsx", expectedCount: 0, public: true },
11623
- { id: "api-mutation", title: "Do not mutate through an API route", outcome: "match", files: [{ path: "app/tasks/page.tsx", source: "await fetch('/api/tasks', { method: 'POST', body });" }], focusPath: "app/tasks/page.tsx", expectedCount: 1, public: true }
11710
+ { id: "api-mutation", title: "Do not mutate through an API route", outcome: "match", files: [{ path: "app/tasks/page.tsx", source: "'use client'; await fetch('/api/tasks', { method: 'POST', body });" }], focusPath: "app/tasks/page.tsx", expectedCount: 1, public: true }
11624
11711
  ]
11625
11712
  };
11626
11713
  var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE", "PATCH"]);
11627
11714
  var AXIOS_MUTATION_METHODS = /* @__PURE__ */ new Set(["post", "put", "delete", "patch"]);
11628
11715
  var SKIP_FILE_REGEX = /(?:\.test\.[jt]sx?$|\.spec\.[jt]sx?$|-(?:test|spec)\.[jt]sx?$|\/tests?\/|\/__tests__\/|\/__testfixtures__\/|\/scripts?\/|\/app\/api\/.*\/route\.[jt]sx?$|\/pages\/api\/)/;
11629
11716
  var NON_REACT_FRAMEWORK_RE2 = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
11717
+ var NEXT_MODULE_PATH_RE2 = /(?:^|[/\\])(?:app|pages)[/\\]/u;
11630
11718
  function getScope(context, node) {
11631
11719
  return context.sourceCode.getScope(node);
11632
11720
  }
@@ -11740,6 +11828,16 @@ var prefer_server_actions_default = createRule({
11740
11828
  const isNonReactFramework = context.sourceCode.ast.body.some(
11741
11829
  (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && NON_REACT_FRAMEWORK_RE2.test(node.source.value)
11742
11830
  );
11831
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
11832
+ (node) => node.type === "ExpressionStatement" && node.expression.type === "Literal" && node.expression.value === "use client"
11833
+ );
11834
+ const hasNextImport = context.sourceCode.ast.body.some(
11835
+ (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && (node.source.value === "next" || node.source.value.startsWith("next/"))
11836
+ );
11837
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE2.test(filename);
11838
+ if (!hasNextEvidence) {
11839
+ return {};
11840
+ }
11743
11841
  return {
11744
11842
  CallExpression(node) {
11745
11843
  if (isNonReactFramework) return;
@@ -14316,7 +14414,7 @@ var rules = {
14316
14414
  };
14317
14415
  var meta = {
14318
14416
  name: "@sarj/eslint-plugin",
14319
- version: "15.2.0"
14417
+ version: "15.3.0"
14320
14418
  };
14321
14419
  var applicationOnlyRules = [
14322
14420
  "no-restricted-library-load",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "15.2.0",
3
+ "version": "15.3.0",
4
4
  "packageManager": "npm@11.19.0",
5
5
  "description": "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
6
6
  "type": "module",