@sarj/eslint-plugin 15.2.0 → 15.4.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
@@ -104,6 +104,7 @@ ${missing.join("\n")}`);
104
104
  function publicExample(example) {
105
105
  return {
106
106
  id: example.id,
107
+ scenarioId: example.scenarioId ?? "primary",
107
108
  title: example.title,
108
109
  outcome: example.outcome,
109
110
  files: example.files.map(publicFile),
@@ -148,9 +149,14 @@ function nativeSpec(config, documentation) {
148
149
  const examples = [...documentation.examples ?? []];
149
150
  examples.forEach(validateExample);
150
151
  assertUnique(examples.map((example) => example.id), "rule example IDs");
151
- const publicOutcomes = new Set(examples.filter((example) => example.public === true).map((example) => example.outcome));
152
- if (publicOutcomes.size > 0 && !(publicOutcomes.has("match") && publicOutcomes.has("no-match"))) {
153
- throw new TypeError("published rule examples must include matching and non-matching cases");
152
+ const publicExamples = examples.filter((example) => example.public === true);
153
+ const scenarios = new Set(publicExamples.map((example) => example.scenarioId ?? "primary"));
154
+ for (const scenario of scenarios) {
155
+ const pair = publicExamples.filter((example) => (example.scenarioId ?? "primary") === scenario);
156
+ const outcomes = new Set(pair.map((example) => example.outcome));
157
+ if (pair.length !== 2 || !outcomes.has("match") || !outcomes.has("no-match")) {
158
+ throw new TypeError(`published example scenario ${scenario} must contain both matching and non-matching cases exactly once`);
159
+ }
154
160
  }
155
161
  const messageIds = Object.keys(meta2.messages).sort();
156
162
  const schema = optionsSchema(meta2.schema);
@@ -171,7 +177,7 @@ function nativeSpec(config, documentation) {
171
177
  references,
172
178
  since: documentation.since ?? null,
173
179
  examples,
174
- publicExamples: examples.filter((example) => example.public === true),
180
+ publicExamples,
175
181
  messageIds,
176
182
  optionsSchema: schema
177
183
  };
@@ -191,6 +197,9 @@ function validateExample(example) {
191
197
  if (!KEBAB_CASE.test(example.id)) {
192
198
  throw new TypeError("example ID must be lowercase kebab-case");
193
199
  }
200
+ if (!KEBAB_CASE.test(example.scenarioId ?? "primary")) {
201
+ throw new TypeError("example scenario must be lowercase kebab-case");
202
+ }
194
203
  if (example.title.trim().length === 0) {
195
204
  throw new TypeError("example title must not be empty");
196
205
  }
@@ -3264,14 +3273,12 @@ var noJsonStringifyErrorDocumentation = {
3264
3273
  rationale: "Native Error details are non-enumerable, so generic JSON serialization discards diagnostic information.",
3265
3274
  remediation: "Serialize explicit error fields or use an error-aware serializer.",
3266
3275
  category: "correctness",
3267
- limitations: ["The rule uses local syntax and naming evidence rather than type information."],
3276
+ limitations: ["The rule uses local catch-binding and constructor provenance rather than type information."],
3268
3277
  examples: [
3269
3278
  { 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
3279
  { 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
3280
  ]
3272
3281
  };
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
3282
  var SAFE_STRING_PROPS = /* @__PURE__ */ new Set(["message", "stack", "name"]);
3276
3283
  var PAYLOAD_PROPS = /* @__PURE__ */ new Set([
3277
3284
  "data",
@@ -3299,6 +3306,21 @@ var BUILTIN_ERROR_CONSTRUCTORS = /* @__PURE__ */ new Set([
3299
3306
  "TypeError",
3300
3307
  "URIError"
3301
3308
  ]);
3309
+ function identifierIsProvenError(identifier, scope) {
3310
+ if (isCatchBinding(scope, identifier.name)) return true;
3311
+ let current = scope;
3312
+ while (current !== null && !current.set.has(identifier.name)) {
3313
+ current = current.upper;
3314
+ }
3315
+ const variable = current?.set.get(identifier.name);
3316
+ if (variable === void 0 || variable.defs.length !== 1) return false;
3317
+ const definition = variable.defs[0];
3318
+ if (definition?.type !== "Variable") return false;
3319
+ const initializer = definition.node.init;
3320
+ return initializer?.type === "NewExpression" && initializer.callee.type === "Identifier" && BUILTIN_ERROR_CONSTRUCTORS.has(initializer.callee.name) && variable.references.every(
3321
+ (reference) => !reference.isWrite() || reference.init === true
3322
+ );
3323
+ }
3302
3324
  function isCatchBinding(scope, name) {
3303
3325
  let current = scope;
3304
3326
  while (current) {
@@ -3314,22 +3336,6 @@ function isCatchBinding(scope, name) {
3314
3336
  }
3315
3337
  return false;
3316
3338
  }
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
3339
  function positiveErrorSubject(test) {
3334
3340
  return instanceofErrorSubject(test) ?? typeGuardSubject(test);
3335
3341
  }
@@ -3433,27 +3439,25 @@ function directLiteralValues(argument) {
3433
3439
  }
3434
3440
  function expressionSuggestsError(expression, scope) {
3435
3441
  if (expression.type === "Identifier") {
3436
- return ERROR_NAME_PATTERN.test(expression.name) || isCatchBinding(scope, expression.name);
3442
+ return identifierIsProvenError(expression, scope);
3443
+ }
3444
+ if (expression.type === "NewExpression" && expression.callee.type === "Identifier") {
3445
+ return BUILTIN_ERROR_CONSTRUCTORS.has(expression.callee.name);
3437
3446
  }
3438
3447
  return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3439
3448
  }
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
- );
3449
+ function memberSuggestsError(member, scope) {
3450
+ const propName2 = !member.computed && member.property.type === "Identifier" ? member.property.name : null;
3451
+ const base = member.object;
3452
+ const baseSuggestsError = base.type === "Identifier" && identifierIsProvenError(base, scope);
3453
+ if (baseSuggestsError) {
3454
+ if (propName2 === null) {
3455
+ return true;
3456
+ }
3457
+ const lowered = propName2.toLowerCase();
3458
+ return !SAFE_STRING_PROPS.has(lowered) && !PAYLOAD_PROPS.has(lowered);
3455
3459
  }
3456
- return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3460
+ return false;
3457
3461
  }
3458
3462
  var no_json_stringify_error_default = createRule({
3459
3463
  name: "no-json-stringify-error",
@@ -3480,9 +3484,8 @@ var no_json_stringify_error_default = createRule({
3480
3484
  return;
3481
3485
  }
3482
3486
  const scope = context.sourceCode.getScope(firstArg);
3483
- const isNestedLiteral = firstArg.type === "ObjectExpression" || firstArg.type === "ArrayExpression";
3484
3487
  const unsafeValue = directLiteralValues(firstArg).find(
3485
- (value) => (isNestedLiteral ? nestedExpressionSuggestsError(value, scope) : expressionSuggestsError(value, scope)) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3488
+ (value) => expressionSuggestsError(value, scope) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3486
3489
  );
3487
3490
  if (unsafeValue === void 0) {
3488
3491
  return;
@@ -4941,6 +4944,7 @@ var ANALYTICS_SEGMENTS2 = /* @__PURE__ */ new Set([
4941
4944
  ]);
4942
4945
  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
4946
  var NON_REACT_FRAMEWORK_RE = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
4947
+ var NEXT_MODULE_PATH_RE = /(?:^|[/\\])(?:app|pages)[/\\]/u;
4944
4948
  function isGlobalFetchCall(node, resolvesToGlobal) {
4945
4949
  const callee = node.callee;
4946
4950
  if (callee.type === "Identifier") {
@@ -5056,6 +5060,13 @@ var no_raw_fetch_outside_clients_default = createRule({
5056
5060
  const nonReactFramework = context.sourceCode.ast.body.some(
5057
5061
  (statement) => statement.type === import_utils25.AST_NODE_TYPES.ImportDeclaration && typeof statement.source.value === "string" && NON_REACT_FRAMEWORK_RE.test(statement.source.value)
5058
5062
  );
5063
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
5064
+ (statement) => statement.type === import_utils25.AST_NODE_TYPES.ExpressionStatement && statement.expression.type === import_utils25.AST_NODE_TYPES.Literal && statement.expression.value === "use client"
5065
+ );
5066
+ const hasNextImport = context.sourceCode.ast.body.some(
5067
+ (statement) => statement.type === import_utils25.AST_NODE_TYPES.ImportDeclaration && typeof statement.source.value === "string" && (statement.source.value === "next" || statement.source.value.startsWith("next/"))
5068
+ );
5069
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE.test(filename);
5059
5070
  function resolvesToGlobal(identifier) {
5060
5071
  const variable = import_utils25.ASTUtils.findVariable(
5061
5072
  context.sourceCode.getScope(identifier),
@@ -5116,7 +5127,7 @@ var no_raw_fetch_outside_clients_default = createRule({
5116
5127
  return resolved?.type === import_utils25.AST_NODE_TYPES.LogicalExpression && resolved.operator === "||" && (isMutationMethod2(resolved.left) || isMutationMethod2(resolved.right));
5117
5128
  }
5118
5129
  function serverActionOwns(node) {
5119
- if (node.callee.type !== import_utils25.AST_NODE_TYPES.Identifier || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5130
+ if (node.callee.type !== import_utils25.AST_NODE_TYPES.Identifier || !hasNextEvidence || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5120
5131
  return false;
5121
5132
  }
5122
5133
  const url = node.arguments[0];
@@ -6335,6 +6346,20 @@ var SAFE_PARSE_CONSTRUCTORS = /* @__PURE__ */ new Set([
6335
6346
  function isBodyDecodeNode(node) {
6336
6347
  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
6348
  }
6349
+ function isSafeParseSupportCall(node) {
6350
+ const callee = node.callee;
6351
+ if (callee.type !== import_utils32.AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== import_utils32.AST_NODE_TYPES.Identifier) {
6352
+ return false;
6353
+ }
6354
+ if (callee.property.name === "isArray" && callee.object.type === import_utils32.AST_NODE_TYPES.Identifier && callee.object.name === "Array") {
6355
+ return true;
6356
+ }
6357
+ if (callee.property.name !== "getItem") return false;
6358
+ if (callee.object.type === import_utils32.AST_NODE_TYPES.Identifier && (callee.object.name === "localStorage" || callee.object.name === "sessionStorage")) {
6359
+ return true;
6360
+ }
6361
+ 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");
6362
+ }
6338
6363
  var BODY_DECODE_METHODS = /* @__PURE__ */ new Set([
6339
6364
  "json",
6340
6365
  "text",
@@ -6403,6 +6428,7 @@ function tryReturnsSafeParse(catchNode) {
6403
6428
  if (current.type === import_utils32.AST_NODE_TYPES.CallExpression || current.type === import_utils32.AST_NODE_TYPES.NewExpression) {
6404
6429
  if (isParseShapedNode(current) || isBodyDecodeNode(current)) {
6405
6430
  sawSafeParse = true;
6431
+ } else if (current.type === import_utils32.AST_NODE_TYPES.CallExpression && isSafeParseSupportCall(current)) {
6406
6432
  } else {
6407
6433
  sawUnsafeOperation = true;
6408
6434
  return;
@@ -10926,7 +10952,70 @@ var bindingValidationPolarity = (test, bindingName) => {
10926
10952
  }
10927
10953
  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
10954
  };
10955
+ 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;
10956
+ var isSamePlainMember = (node, access) => {
10957
+ const candidate = plainMemberAccess(node);
10958
+ return candidate !== null && candidate.object === access.object && candidate.property === access.property;
10959
+ };
10929
10960
  var nodeWithin2 = (node, container) => node.range[0] >= container.range[0] && node.range[1] <= container.range[1];
10961
+ var isUseWithinValidatedBranch = (node, bindingName) => {
10962
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10963
+ if (current.type === import_utils57.AST_NODE_TYPES.ConditionalExpression) {
10964
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10965
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10966
+ return true;
10967
+ }
10968
+ }
10969
+ if (current.type === import_utils57.AST_NODE_TYPES.IfStatement) {
10970
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10971
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10972
+ return true;
10973
+ }
10974
+ }
10975
+ 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) {
10976
+ return false;
10977
+ }
10978
+ }
10979
+ return false;
10980
+ };
10981
+ var isMemberUseWithinValidatedBranch = (node, access) => {
10982
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10983
+ if (current.type === import_utils57.AST_NODE_TYPES.ConditionalExpression) {
10984
+ const polarity = memberValidationPolarity(current.test, access);
10985
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10986
+ return true;
10987
+ }
10988
+ }
10989
+ if (current.type === import_utils57.AST_NODE_TYPES.IfStatement) {
10990
+ const polarity = memberValidationPolarity(current.test, access);
10991
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10992
+ return true;
10993
+ }
10994
+ }
10995
+ 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) {
10996
+ return false;
10997
+ }
10998
+ }
10999
+ return false;
11000
+ };
11001
+ var memberValidationPolarity = (test, access) => {
11002
+ if (test.type === import_utils57.AST_NODE_TYPES.UnaryExpression && test.operator === "!") {
11003
+ const inner = memberValidationPolarity(test.argument, access);
11004
+ return inner === "valid-when-true" ? "valid-when-false" : inner === "valid-when-false" ? "valid-when-true" : null;
11005
+ }
11006
+ if (test.type === import_utils57.AST_NODE_TYPES.BinaryExpression) {
11007
+ const isMatchingTypeof = (node) => node.type === import_utils57.AST_NODE_TYPES.UnaryExpression && node.operator === "typeof" && isSamePlainMember(node.argument, access);
11008
+ const isPrimitiveType = (node) => node.type === import_utils57.AST_NODE_TYPES.Literal && typeof node.value === "string" && PRIMITIVE_TYPEOF_RESULTS.has(node.value);
11009
+ if (!(isMatchingTypeof(test.left) && isPrimitiveType(test.right) || isMatchingTypeof(test.right) && isPrimitiveType(test.left))) {
11010
+ return null;
11011
+ }
11012
+ if (test.operator === "===" || test.operator === "==") {
11013
+ return "valid-when-true";
11014
+ }
11015
+ return test.operator === "!==" || test.operator === "!=" ? "valid-when-false" : null;
11016
+ }
11017
+ 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;
11018
+ };
10930
11019
  var isFullyValidatedExtractedBinding = (member, source, context) => {
10931
11020
  const isValidationReference = (identifier) => {
10932
11021
  for (let current = identifier.parent; current !== void 0 && current !== null; current = current.parent) {
@@ -11196,7 +11285,14 @@ var prefer_schema_for_api_payload_default = createRule({
11196
11285
  return;
11197
11286
  }
11198
11287
  const variable = obj?.type === import_utils57.AST_NODE_TYPES.Identifier ? unvalidatedVariableRef(obj, scope, unvalidatedVariables) : null;
11199
- if (variable !== null) {
11288
+ if (variable !== null && obj?.type === import_utils57.AST_NODE_TYPES.Identifier) {
11289
+ if (isUseWithinValidatedBranch(node, obj.name)) {
11290
+ return;
11291
+ }
11292
+ const access = plainMemberAccess(node);
11293
+ if (access !== null && isMemberUseWithinValidatedBranch(node, access)) {
11294
+ return;
11295
+ }
11200
11296
  if (isFullyValidatedExtractedBinding(node, variable, context)) {
11201
11297
  return;
11202
11298
  }
@@ -11648,16 +11744,17 @@ var preferServerActionsDocumentation = {
11648
11744
  rationale: "Server Actions preserve typed application calls and avoid an internal JSON request-response boundary.",
11649
11745
  remediation: "Move the mutation into a Server Action and invoke that action from the React client.",
11650
11746
  category: "architecture",
11651
- limitations: ["Only statically recognizable /api/ mutations in applicable React modules are reported."],
11747
+ 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
11748
  examples: [
11653
11749
  { 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 }
11750
+ { 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
11751
  ]
11656
11752
  };
11657
11753
  var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE", "PATCH"]);
11658
11754
  var AXIOS_MUTATION_METHODS = /* @__PURE__ */ new Set(["post", "put", "delete", "patch"]);
11659
11755
  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
11756
  var NON_REACT_FRAMEWORK_RE2 = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
11757
+ var NEXT_MODULE_PATH_RE2 = /(?:^|[/\\])(?:app|pages)[/\\]/u;
11661
11758
  function getScope(context, node) {
11662
11759
  return context.sourceCode.getScope(node);
11663
11760
  }
@@ -11771,6 +11868,16 @@ var prefer_server_actions_default = createRule({
11771
11868
  const isNonReactFramework = context.sourceCode.ast.body.some(
11772
11869
  (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && NON_REACT_FRAMEWORK_RE2.test(node.source.value)
11773
11870
  );
11871
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
11872
+ (node) => node.type === "ExpressionStatement" && node.expression.type === "Literal" && node.expression.value === "use client"
11873
+ );
11874
+ const hasNextImport = context.sourceCode.ast.body.some(
11875
+ (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && (node.source.value === "next" || node.source.value.startsWith("next/"))
11876
+ );
11877
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE2.test(filename);
11878
+ if (!hasNextEvidence) {
11879
+ return {};
11880
+ }
11774
11881
  return {
11775
11882
  CallExpression(node) {
11776
11883
  if (isNonReactFramework) return;
@@ -14338,7 +14445,7 @@ var rules = {
14338
14445
  };
14339
14446
  var meta = {
14340
14447
  name: "@sarj/eslint-plugin",
14341
- version: "15.2.0"
14448
+ version: "15.4.0"
14342
14449
  };
14343
14450
  var applicationOnlyRules = [
14344
14451
  "no-restricted-library-load",
package/dist/index.d.cts CHANGED
@@ -25,6 +25,7 @@ interface ExampleFile {
25
25
  /** A reviewed example. It remains private unless `public: true` is explicit. */
26
26
  interface RuleExample {
27
27
  readonly id: string;
28
+ readonly scenarioId?: string;
28
29
  readonly title: string;
29
30
  readonly outcome: ExampleOutcome;
30
31
  readonly files: readonly ExampleFile[];
@@ -414,7 +415,7 @@ type FlatPreset = {
414
415
  declare const plugin: {
415
416
  readonly meta: {
416
417
  readonly name: "@sarj/eslint-plugin";
417
- readonly version: "15.2.0";
418
+ readonly version: "15.4.0";
418
419
  };
419
420
  readonly rules: {
420
421
  readonly "duplicate-test-body": DocumentedRule<readonly [], "duplicateTestBody">;
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ interface ExampleFile {
25
25
  /** A reviewed example. It remains private unless `public: true` is explicit. */
26
26
  interface RuleExample {
27
27
  readonly id: string;
28
+ readonly scenarioId?: string;
28
29
  readonly title: string;
29
30
  readonly outcome: ExampleOutcome;
30
31
  readonly files: readonly ExampleFile[];
@@ -414,7 +415,7 @@ type FlatPreset = {
414
415
  declare const plugin: {
415
416
  readonly meta: {
416
417
  readonly name: "@sarj/eslint-plugin";
417
- readonly version: "15.2.0";
418
+ readonly version: "15.4.0";
418
419
  };
419
420
  readonly rules: {
420
421
  readonly "duplicate-test-body": DocumentedRule<readonly [], "duplicateTestBody">;
package/dist/index.js CHANGED
@@ -61,6 +61,7 @@ ${missing.join("\n")}`);
61
61
  function publicExample(example) {
62
62
  return {
63
63
  id: example.id,
64
+ scenarioId: example.scenarioId ?? "primary",
64
65
  title: example.title,
65
66
  outcome: example.outcome,
66
67
  files: example.files.map(publicFile),
@@ -105,9 +106,14 @@ function nativeSpec(config, documentation) {
105
106
  const examples = [...documentation.examples ?? []];
106
107
  examples.forEach(validateExample);
107
108
  assertUnique(examples.map((example) => example.id), "rule example IDs");
108
- const publicOutcomes = new Set(examples.filter((example) => example.public === true).map((example) => example.outcome));
109
- if (publicOutcomes.size > 0 && !(publicOutcomes.has("match") && publicOutcomes.has("no-match"))) {
110
- throw new TypeError("published rule examples must include matching and non-matching cases");
109
+ const publicExamples = examples.filter((example) => example.public === true);
110
+ const scenarios = new Set(publicExamples.map((example) => example.scenarioId ?? "primary"));
111
+ for (const scenario of scenarios) {
112
+ const pair = publicExamples.filter((example) => (example.scenarioId ?? "primary") === scenario);
113
+ const outcomes = new Set(pair.map((example) => example.outcome));
114
+ if (pair.length !== 2 || !outcomes.has("match") || !outcomes.has("no-match")) {
115
+ throw new TypeError(`published example scenario ${scenario} must contain both matching and non-matching cases exactly once`);
116
+ }
111
117
  }
112
118
  const messageIds = Object.keys(meta2.messages).sort();
113
119
  const schema = optionsSchema(meta2.schema);
@@ -128,7 +134,7 @@ function nativeSpec(config, documentation) {
128
134
  references,
129
135
  since: documentation.since ?? null,
130
136
  examples,
131
- publicExamples: examples.filter((example) => example.public === true),
137
+ publicExamples,
132
138
  messageIds,
133
139
  optionsSchema: schema
134
140
  };
@@ -148,6 +154,9 @@ function validateExample(example) {
148
154
  if (!KEBAB_CASE.test(example.id)) {
149
155
  throw new TypeError("example ID must be lowercase kebab-case");
150
156
  }
157
+ if (!KEBAB_CASE.test(example.scenarioId ?? "primary")) {
158
+ throw new TypeError("example scenario must be lowercase kebab-case");
159
+ }
151
160
  if (example.title.trim().length === 0) {
152
161
  throw new TypeError("example title must not be empty");
153
162
  }
@@ -3224,14 +3233,12 @@ var noJsonStringifyErrorDocumentation = {
3224
3233
  rationale: "Native Error details are non-enumerable, so generic JSON serialization discards diagnostic information.",
3225
3234
  remediation: "Serialize explicit error fields or use an error-aware serializer.",
3226
3235
  category: "correctness",
3227
- limitations: ["The rule uses local syntax and naming evidence rather than type information."],
3236
+ limitations: ["The rule uses local catch-binding and constructor provenance rather than type information."],
3228
3237
  examples: [
3229
3238
  { 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
3239
  { 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
3240
  ]
3232
3241
  };
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
3242
  var SAFE_STRING_PROPS = /* @__PURE__ */ new Set(["message", "stack", "name"]);
3236
3243
  var PAYLOAD_PROPS = /* @__PURE__ */ new Set([
3237
3244
  "data",
@@ -3259,6 +3266,21 @@ var BUILTIN_ERROR_CONSTRUCTORS = /* @__PURE__ */ new Set([
3259
3266
  "TypeError",
3260
3267
  "URIError"
3261
3268
  ]);
3269
+ function identifierIsProvenError(identifier, scope) {
3270
+ if (isCatchBinding(scope, identifier.name)) return true;
3271
+ let current = scope;
3272
+ while (current !== null && !current.set.has(identifier.name)) {
3273
+ current = current.upper;
3274
+ }
3275
+ const variable = current?.set.get(identifier.name);
3276
+ if (variable === void 0 || variable.defs.length !== 1) return false;
3277
+ const definition = variable.defs[0];
3278
+ if (definition?.type !== "Variable") return false;
3279
+ const initializer = definition.node.init;
3280
+ return initializer?.type === "NewExpression" && initializer.callee.type === "Identifier" && BUILTIN_ERROR_CONSTRUCTORS.has(initializer.callee.name) && variable.references.every(
3281
+ (reference) => !reference.isWrite() || reference.init === true
3282
+ );
3283
+ }
3262
3284
  function isCatchBinding(scope, name) {
3263
3285
  let current = scope;
3264
3286
  while (current) {
@@ -3274,22 +3296,6 @@ function isCatchBinding(scope, name) {
3274
3296
  }
3275
3297
  return false;
3276
3298
  }
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
3299
  function positiveErrorSubject(test) {
3294
3300
  return instanceofErrorSubject(test) ?? typeGuardSubject(test);
3295
3301
  }
@@ -3393,27 +3399,25 @@ function directLiteralValues(argument) {
3393
3399
  }
3394
3400
  function expressionSuggestsError(expression, scope) {
3395
3401
  if (expression.type === "Identifier") {
3396
- return ERROR_NAME_PATTERN.test(expression.name) || isCatchBinding(scope, expression.name);
3402
+ return identifierIsProvenError(expression, scope);
3403
+ }
3404
+ if (expression.type === "NewExpression" && expression.callee.type === "Identifier") {
3405
+ return BUILTIN_ERROR_CONSTRUCTORS.has(expression.callee.name);
3397
3406
  }
3398
3407
  return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3399
3408
  }
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
- );
3409
+ function memberSuggestsError(member, scope) {
3410
+ const propName2 = !member.computed && member.property.type === "Identifier" ? member.property.name : null;
3411
+ const base = member.object;
3412
+ const baseSuggestsError = base.type === "Identifier" && identifierIsProvenError(base, scope);
3413
+ if (baseSuggestsError) {
3414
+ if (propName2 === null) {
3415
+ return true;
3416
+ }
3417
+ const lowered = propName2.toLowerCase();
3418
+ return !SAFE_STRING_PROPS.has(lowered) && !PAYLOAD_PROPS.has(lowered);
3415
3419
  }
3416
- return expression.type === "MemberExpression" && memberSuggestsError(expression, scope);
3420
+ return false;
3417
3421
  }
3418
3422
  var no_json_stringify_error_default = createRule({
3419
3423
  name: "no-json-stringify-error",
@@ -3440,9 +3444,8 @@ var no_json_stringify_error_default = createRule({
3440
3444
  return;
3441
3445
  }
3442
3446
  const scope = context.sourceCode.getScope(firstArg);
3443
- const isNestedLiteral = firstArg.type === "ObjectExpression" || firstArg.type === "ArrayExpression";
3444
3447
  const unsafeValue = directLiteralValues(firstArg).find(
3445
- (value) => (isNestedLiteral ? nestedExpressionSuggestsError(value, scope) : expressionSuggestsError(value, scope)) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3448
+ (value) => expressionSuggestsError(value, scope) && !isGuardedByInstanceofError(node, value, context.sourceCode) && !isNarrowedByEarlyReturn(node, value, context.sourceCode)
3446
3449
  );
3447
3450
  if (unsafeValue === void 0) {
3448
3451
  return;
@@ -4903,6 +4906,7 @@ var ANALYTICS_SEGMENTS2 = /* @__PURE__ */ new Set([
4903
4906
  ]);
4904
4907
  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
4908
  var NON_REACT_FRAMEWORK_RE = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
4909
+ var NEXT_MODULE_PATH_RE = /(?:^|[/\\])(?:app|pages)[/\\]/u;
4906
4910
  function isGlobalFetchCall(node, resolvesToGlobal) {
4907
4911
  const callee = node.callee;
4908
4912
  if (callee.type === "Identifier") {
@@ -5018,6 +5022,13 @@ var no_raw_fetch_outside_clients_default = createRule({
5018
5022
  const nonReactFramework = context.sourceCode.ast.body.some(
5019
5023
  (statement) => statement.type === AST_NODE_TYPES18.ImportDeclaration && typeof statement.source.value === "string" && NON_REACT_FRAMEWORK_RE.test(statement.source.value)
5020
5024
  );
5025
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
5026
+ (statement) => statement.type === AST_NODE_TYPES18.ExpressionStatement && statement.expression.type === AST_NODE_TYPES18.Literal && statement.expression.value === "use client"
5027
+ );
5028
+ const hasNextImport = context.sourceCode.ast.body.some(
5029
+ (statement) => statement.type === AST_NODE_TYPES18.ImportDeclaration && typeof statement.source.value === "string" && (statement.source.value === "next" || statement.source.value.startsWith("next/"))
5030
+ );
5031
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE.test(filename);
5021
5032
  function resolvesToGlobal(identifier) {
5022
5033
  const variable = ASTUtils5.findVariable(
5023
5034
  context.sourceCode.getScope(identifier),
@@ -5078,7 +5089,7 @@ var no_raw_fetch_outside_clients_default = createRule({
5078
5089
  return resolved?.type === AST_NODE_TYPES18.LogicalExpression && resolved.operator === "||" && (isMutationMethod2(resolved.left) || isMutationMethod2(resolved.right));
5079
5090
  }
5080
5091
  function serverActionOwns(node) {
5081
- if (node.callee.type !== AST_NODE_TYPES18.Identifier || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5092
+ if (node.callee.type !== AST_NODE_TYPES18.Identifier || !hasNextEvidence || SERVER_ACTION_SKIP_FILE_RE.test(filename) || nonReactFramework) {
5082
5093
  return false;
5083
5094
  }
5084
5095
  const url = node.arguments[0];
@@ -6297,6 +6308,20 @@ var SAFE_PARSE_CONSTRUCTORS = /* @__PURE__ */ new Set([
6297
6308
  function isBodyDecodeNode(node) {
6298
6309
  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
6310
  }
6311
+ function isSafeParseSupportCall(node) {
6312
+ const callee = node.callee;
6313
+ if (callee.type !== AST_NODE_TYPES23.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES23.Identifier) {
6314
+ return false;
6315
+ }
6316
+ if (callee.property.name === "isArray" && callee.object.type === AST_NODE_TYPES23.Identifier && callee.object.name === "Array") {
6317
+ return true;
6318
+ }
6319
+ if (callee.property.name !== "getItem") return false;
6320
+ if (callee.object.type === AST_NODE_TYPES23.Identifier && (callee.object.name === "localStorage" || callee.object.name === "sessionStorage")) {
6321
+ return true;
6322
+ }
6323
+ 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");
6324
+ }
6300
6325
  var BODY_DECODE_METHODS = /* @__PURE__ */ new Set([
6301
6326
  "json",
6302
6327
  "text",
@@ -6365,6 +6390,7 @@ function tryReturnsSafeParse(catchNode) {
6365
6390
  if (current.type === AST_NODE_TYPES23.CallExpression || current.type === AST_NODE_TYPES23.NewExpression) {
6366
6391
  if (isParseShapedNode(current) || isBodyDecodeNode(current)) {
6367
6392
  sawSafeParse = true;
6393
+ } else if (current.type === AST_NODE_TYPES23.CallExpression && isSafeParseSupportCall(current)) {
6368
6394
  } else {
6369
6395
  sawUnsafeOperation = true;
6370
6396
  return;
@@ -10895,7 +10921,70 @@ var bindingValidationPolarity = (test, bindingName) => {
10895
10921
  }
10896
10922
  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
10923
  };
10924
+ 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;
10925
+ var isSamePlainMember = (node, access) => {
10926
+ const candidate = plainMemberAccess(node);
10927
+ return candidate !== null && candidate.object === access.object && candidate.property === access.property;
10928
+ };
10898
10929
  var nodeWithin2 = (node, container) => node.range[0] >= container.range[0] && node.range[1] <= container.range[1];
10930
+ var isUseWithinValidatedBranch = (node, bindingName) => {
10931
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10932
+ if (current.type === AST_NODE_TYPES45.ConditionalExpression) {
10933
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10934
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10935
+ return true;
10936
+ }
10937
+ }
10938
+ if (current.type === AST_NODE_TYPES45.IfStatement) {
10939
+ const polarity = bindingValidationPolarity(current.test, bindingName);
10940
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10941
+ return true;
10942
+ }
10943
+ }
10944
+ if (current.type === AST_NODE_TYPES45.FunctionDeclaration || current.type === AST_NODE_TYPES45.FunctionExpression || current.type === AST_NODE_TYPES45.ArrowFunctionExpression) {
10945
+ return false;
10946
+ }
10947
+ }
10948
+ return false;
10949
+ };
10950
+ var isMemberUseWithinValidatedBranch = (node, access) => {
10951
+ for (let current = node.parent; current !== void 0 && current !== null; current = current.parent) {
10952
+ if (current.type === AST_NODE_TYPES45.ConditionalExpression) {
10953
+ const polarity = memberValidationPolarity(current.test, access);
10954
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && nodeWithin2(node, current.alternate)) {
10955
+ return true;
10956
+ }
10957
+ }
10958
+ if (current.type === AST_NODE_TYPES45.IfStatement) {
10959
+ const polarity = memberValidationPolarity(current.test, access);
10960
+ if (polarity === "valid-when-true" && nodeWithin2(node, current.consequent) || polarity === "valid-when-false" && current.alternate !== null && nodeWithin2(node, current.alternate)) {
10961
+ return true;
10962
+ }
10963
+ }
10964
+ if (current.type === AST_NODE_TYPES45.FunctionDeclaration || current.type === AST_NODE_TYPES45.FunctionExpression || current.type === AST_NODE_TYPES45.ArrowFunctionExpression) {
10965
+ return false;
10966
+ }
10967
+ }
10968
+ return false;
10969
+ };
10970
+ var memberValidationPolarity = (test, access) => {
10971
+ if (test.type === AST_NODE_TYPES45.UnaryExpression && test.operator === "!") {
10972
+ const inner = memberValidationPolarity(test.argument, access);
10973
+ return inner === "valid-when-true" ? "valid-when-false" : inner === "valid-when-false" ? "valid-when-true" : null;
10974
+ }
10975
+ if (test.type === AST_NODE_TYPES45.BinaryExpression) {
10976
+ const isMatchingTypeof = (node) => node.type === AST_NODE_TYPES45.UnaryExpression && node.operator === "typeof" && isSamePlainMember(node.argument, access);
10977
+ const isPrimitiveType = (node) => node.type === AST_NODE_TYPES45.Literal && typeof node.value === "string" && PRIMITIVE_TYPEOF_RESULTS.has(node.value);
10978
+ if (!(isMatchingTypeof(test.left) && isPrimitiveType(test.right) || isMatchingTypeof(test.right) && isPrimitiveType(test.left))) {
10979
+ return null;
10980
+ }
10981
+ if (test.operator === "===" || test.operator === "==") {
10982
+ return "valid-when-true";
10983
+ }
10984
+ return test.operator === "!==" || test.operator === "!=" ? "valid-when-false" : null;
10985
+ }
10986
+ 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;
10987
+ };
10899
10988
  var isFullyValidatedExtractedBinding = (member, source, context) => {
10900
10989
  const isValidationReference = (identifier) => {
10901
10990
  for (let current = identifier.parent; current !== void 0 && current !== null; current = current.parent) {
@@ -11165,7 +11254,14 @@ var prefer_schema_for_api_payload_default = createRule({
11165
11254
  return;
11166
11255
  }
11167
11256
  const variable = obj?.type === AST_NODE_TYPES45.Identifier ? unvalidatedVariableRef(obj, scope, unvalidatedVariables) : null;
11168
- if (variable !== null) {
11257
+ if (variable !== null && obj?.type === AST_NODE_TYPES45.Identifier) {
11258
+ if (isUseWithinValidatedBranch(node, obj.name)) {
11259
+ return;
11260
+ }
11261
+ const access = plainMemberAccess(node);
11262
+ if (access !== null && isMemberUseWithinValidatedBranch(node, access)) {
11263
+ return;
11264
+ }
11169
11265
  if (isFullyValidatedExtractedBinding(node, variable, context)) {
11170
11266
  return;
11171
11267
  }
@@ -11617,16 +11713,17 @@ var preferServerActionsDocumentation = {
11617
11713
  rationale: "Server Actions preserve typed application calls and avoid an internal JSON request-response boundary.",
11618
11714
  remediation: "Move the mutation into a Server Action and invoke that action from the React client.",
11619
11715
  category: "architecture",
11620
- limitations: ["Only statically recognizable /api/ mutations in applicable React modules are reported."],
11716
+ 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
11717
  examples: [
11622
11718
  { 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 }
11719
+ { 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
11720
  ]
11625
11721
  };
11626
11722
  var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE", "PATCH"]);
11627
11723
  var AXIOS_MUTATION_METHODS = /* @__PURE__ */ new Set(["post", "put", "delete", "patch"]);
11628
11724
  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
11725
  var NON_REACT_FRAMEWORK_RE2 = /^(?:@angular\/|@nestjs\/|vue$|vue\/|svelte$|svelte\/|solid-js$|solid-js\/|@ember\/|rxjs$|rxjs\/)/;
11726
+ var NEXT_MODULE_PATH_RE2 = /(?:^|[/\\])(?:app|pages)[/\\]/u;
11630
11727
  function getScope(context, node) {
11631
11728
  return context.sourceCode.getScope(node);
11632
11729
  }
@@ -11740,6 +11837,16 @@ var prefer_server_actions_default = createRule({
11740
11837
  const isNonReactFramework = context.sourceCode.ast.body.some(
11741
11838
  (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && NON_REACT_FRAMEWORK_RE2.test(node.source.value)
11742
11839
  );
11840
+ const hasUseClientDirective = context.sourceCode.ast.body.some(
11841
+ (node) => node.type === "ExpressionStatement" && node.expression.type === "Literal" && node.expression.value === "use client"
11842
+ );
11843
+ const hasNextImport = context.sourceCode.ast.body.some(
11844
+ (node) => node.type === "ImportDeclaration" && typeof node.source.value === "string" && (node.source.value === "next" || node.source.value.startsWith("next/"))
11845
+ );
11846
+ const hasNextEvidence = hasNextImport || hasUseClientDirective && NEXT_MODULE_PATH_RE2.test(filename);
11847
+ if (!hasNextEvidence) {
11848
+ return {};
11849
+ }
11743
11850
  return {
11744
11851
  CallExpression(node) {
11745
11852
  if (isNonReactFramework) return;
@@ -14316,7 +14423,7 @@ var rules = {
14316
14423
  };
14317
14424
  var meta = {
14318
14425
  name: "@sarj/eslint-plugin",
14319
- version: "15.2.0"
14426
+ version: "15.4.0"
14320
14427
  };
14321
14428
  var applicationOnlyRules = [
14322
14429
  "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.4.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",