@sarj/eslint-plugin 16.15.0 → 16.15.2

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
@@ -52,7 +52,7 @@ module.exports = __toCommonJS(src_exports);
52
52
  // package.json
53
53
  var package_default = {
54
54
  name: "@sarj/eslint-plugin",
55
- version: "16.15.0",
55
+ version: "16.15.2",
56
56
  packageManager: "npm@12.2.0",
57
57
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
58
58
  type: "module",
@@ -3906,10 +3906,13 @@ var NO_CLIENT_SIDE_DATA_FETCHING_DOCUMENTATION = {
3906
3906
  examples: [
3907
3907
  {
3908
3908
  id: "effect-without-fetch",
3909
- title: "An effect performs no data request",
3909
+ title: "Load data while rendering on the server",
3910
3910
  outcome: "no-match",
3911
- files: [{ path: "src/users.tsx", source: "import { useEffect } from 'react'; useEffect(() => { console.log('mounted'); }, []);" }],
3912
- focusPath: "src/users.tsx",
3911
+ files: [
3912
+ { path: "app/users/page.tsx", source: "import { loadUsers } from './users-client';\nexport default async function Users() { const users = await loadUsers(); return <pre>{JSON.stringify(users)}</pre>; }" },
3913
+ { path: "app/users/users-client.ts", source: "export async function loadUsers(): Promise<unknown> { const response = await fetch('https://api.example.com/users'); return response.json(); }" }
3914
+ ],
3915
+ focusPath: "app/users/page.tsx",
3913
3916
  expectedCount: 0,
3914
3917
  public: true
3915
3918
  },
@@ -3917,7 +3920,7 @@ var NO_CLIENT_SIDE_DATA_FETCHING_DOCUMENTATION = {
3917
3920
  id: "fetch-inside-effect",
3918
3921
  title: "An effect starts a data request",
3919
3922
  outcome: "match",
3920
- files: [{ path: "src/users.tsx", source: "useEffect(() => { fetch('/api/users'); }, []);" }],
3923
+ files: [{ path: "src/users.tsx", source: "'use client'; import { useEffect } from 'react';\nexport function Users() { useEffect(() => { fetch('/api/users'); }, []); return null; }" }],
3921
3924
  focusPath: "src/users.tsx",
3922
3925
  expectedCount: 1,
3923
3926
  public: true
@@ -6058,11 +6061,11 @@ var NO_FAT_TRY_BLOCKS_DOCUMENTATION = {
6058
6061
  examples: [
6059
6062
  {
6060
6063
  id: "focused-try-block",
6061
- title: "Three selected operations stay within the default threshold",
6064
+ title: "Keep only operations with the same recovery policy in the try block",
6062
6065
  outcome: "no-match",
6063
6066
  files: [{
6064
6067
  path: "src/load.ts",
6065
- source: "function f() { try { const a = one(); const b = two(); const c = three(); } catch (error) { handle(error); } finish(); }"
6068
+ source: "function f() { try { const a = one(); const b = two(); const c = three(); } catch (error) { handle(error); } const d = four(); finish(); }"
6066
6069
  }],
6067
6070
  focusPath: "src/load.ts",
6068
6071
  expectedCount: 0,
@@ -7850,7 +7853,7 @@ var NO_JSON_STRINGIFY_OBJECT_EQUALITY_DOCUMENTATION = {
7850
7853
  id: "domain-comparator",
7851
7854
  title: "Use an explicit structural comparator",
7852
7855
  outcome: "no-match",
7853
- files: [{ path: "src/compare.ts", source: "const same = sameRecord(actual, expected);" }],
7856
+ files: [{ path: "src/compare.ts", source: "type RecordState = { id: string; state: string };\nfunction sameRecord(actual: RecordState, expected: RecordState): boolean { return actual.id === expected.id && actual.state === expected.state; }\nconst same = sameRecord({ id: '1', state: 'ready' }, { state: 'ready', id: '1' });" }],
7854
7857
  focusPath: "src/compare.ts",
7855
7858
  expectedCount: 0,
7856
7859
  public: true
@@ -9068,14 +9071,19 @@ var NO_BARE_RETURN_FROM_TEST_CATCH_DOCUMENTATION = {
9068
9071
  remediation: "Rethrow the error, assert on it, or use the runner's explicit skip mechanism when the capability is optional.",
9069
9072
  category: "testing",
9070
9073
  filePatterns: ["**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**"],
9071
- limitations: ["Only bare returns owned by a direct supported test callback and followed lexically by a framework assertion are reported. A runner skip suppresses the finding only when it is an unconditional earlier statement in the return's block."],
9074
+ limitations: ["Only bare returns owned by an inline supported test callback and followed lexically by a framework assertion are reported, including each/for tables and runIf/skipIf builders. Custom builders and indirect callback references are excluded. A runner skip suppresses the finding only when it is an unconditional earlier statement in the return's block. Vitest imports and unshadowed runner globals support TestContext.skip on the first ordinary-test parameter or second for-table parameter, including a directly destructured skip alias; each-table data arguments, reassigned bindings, overwritten skip methods and dynamic skip conditions are excluded."],
9072
9075
  examples: [
9073
9076
  { id: "rethrow", title: "Preserve the failure", outcome: "no-match", files: [{ path: "src/codec.test.ts", source: "test('decodes', () => { try { decode(); } catch (error) { throw error; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 0, public: true },
9074
- { id: "bare-return", title: "Do not silently pass", outcome: "match", files: [{ path: "src/codec.test.ts", source: "test('decodes', () => { try { decode(); } catch { return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true }
9077
+ { id: "bare-return", title: "Do not silently pass", outcome: "match", files: [{ path: "src/codec.test.ts", source: "test('decodes', () => { try { decode(); } catch { return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true },
9078
+ { id: "table-bare-return", scenarioId: "table-catch", title: "Case tables must preserve failures", outcome: "match", files: [{ path: "src/codec.test.ts", source: "test.each(['valid', 'invalid'])('decodes %s', (input) => { try { decode(input); } catch { return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true },
9079
+ { id: "table-rethrow", scenarioId: "table-catch", title: "Each table case propagates its failure", outcome: "no-match", files: [{ path: "src/codec.test.ts", source: "test.each(['valid', 'invalid'])('decodes %s', (input) => { try { decode(input); } catch (error) { throw error; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 0, public: true },
9080
+ { id: "table-context-skip", scenarioId: "context-skip", title: "A Vitest context skip preserves an optional table case", outcome: "no-match", files: [{ path: "src/codec.test.ts", source: "import {test, expect} from 'vitest'; test.for(['optional'])('decodes %s', (input, context) => { try { decode(input); } catch { context.skip('codec unavailable'); return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 0, public: true },
9081
+ { id: "table-context-false-skip", scenarioId: "context-skip", title: "A false skip condition does not preserve the failure", outcome: "match", files: [{ path: "src/codec.test.ts", source: "import {test, expect} from 'vitest'; test.for(['optional'])('decodes %s', (input, context) => { try { decode(input); } catch { context.skip(false); return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true }
9075
9082
  ]
9076
9083
  };
9077
9084
  var TEST_NAMES = /* @__PURE__ */ new Set(["it", "test"]);
9078
9085
  var TEST_MODIFIERS2 = /* @__PURE__ */ new Set(["concurrent", "fails", "only", "sequential", "skip"]);
9086
+ var TEST_BUILDERS = /* @__PURE__ */ new Set(["each", "for", "runIf", "skipIf"]);
9079
9087
  var ASSERTION_NAMES = /* @__PURE__ */ new Set(["assert", "assertType", "expect", "expectTypeOf"]);
9080
9088
  var FUNCTION_TYPES3 = /* @__PURE__ */ new Set([import_utils51.AST_NODE_TYPES.ArrowFunctionExpression, import_utils51.AST_NODE_TYPES.FunctionExpression, import_utils51.AST_NODE_TYPES.FunctionDeclaration]);
9081
9089
  function staticMemberName3(node) {
@@ -9099,6 +9107,12 @@ function isDirectTestCallback(node, context) {
9099
9107
  function testRoot(callee) {
9100
9108
  callee = unwrapExpression(callee);
9101
9109
  if (callee.type === import_utils51.AST_NODE_TYPES.Identifier) return callee;
9110
+ if (callee.type === import_utils51.AST_NODE_TYPES.CallExpression || callee.type === import_utils51.AST_NODE_TYPES.TaggedTemplateExpression) {
9111
+ const builder = unwrapExpression(callee.type === import_utils51.AST_NODE_TYPES.CallExpression ? callee.callee : callee.tag);
9112
+ if (builder.type !== import_utils51.AST_NODE_TYPES.MemberExpression) return null;
9113
+ const name = staticMemberName3(builder);
9114
+ return name !== null && (callee.type === import_utils51.AST_NODE_TYPES.TaggedTemplateExpression ? name === "each" || name === "for" : TEST_BUILDERS.has(name)) ? testRoot(builder.object) : null;
9115
+ }
9102
9116
  if (callee.type !== import_utils51.AST_NODE_TYPES.MemberExpression) return null;
9103
9117
  const modifier = staticMemberName3(callee);
9104
9118
  return modifier !== null && TEST_MODIFIERS2.has(modifier) ? testRoot(callee.object) : null;
@@ -9122,11 +9136,67 @@ function isExplicitSkip(node, context) {
9122
9136
  const root = rootIdentifier2(unwrappedNodeCallee.object);
9123
9137
  return root !== null && TEST_NAMES.has(runtimeTestFrameworkName(context.sourceCode, root, "test") ?? "");
9124
9138
  }
9125
- function hasDominatingExplicitSkip(node, context) {
9139
+ function isUnconditionalContextSkip(node, owner, context) {
9140
+ if (node.type !== import_utils51.AST_NODE_TYPES.CallExpression || node.optional) return false;
9141
+ const first = node.arguments[0] === void 0 ? void 0 : unwrapExpression(node.arguments[0]);
9142
+ const note = first?.type === import_utils51.AST_NODE_TYPES.Literal && typeof first.value === "string" || first?.type === import_utils51.AST_NODE_TYPES.TemplateLiteral && first.expressions.length === 0;
9143
+ const always = first?.type === import_utils51.AST_NODE_TYPES.Literal && first.value === true;
9144
+ if (node.arguments.length !== 0 && !(node.arguments.length === 1 && note) && !(node.arguments.length <= 2 && always)) return false;
9145
+ const parameter = testContextParameter(owner, context);
9146
+ const callee = unwrapExpression(node.callee);
9147
+ if (parameter?.type === import_utils51.AST_NODE_TYPES.Identifier && callee.type === import_utils51.AST_NODE_TYPES.MemberExpression && !callee.optional && staticMemberName3(callee) === "skip") {
9148
+ const receiver = unwrapExpression(callee.object);
9149
+ return receiver.type === import_utils51.AST_NODE_TYPES.Identifier && isStableParameterReference(receiver, parameter, context, true);
9150
+ }
9151
+ if (parameter?.type !== import_utils51.AST_NODE_TYPES.ObjectPattern || callee.type !== import_utils51.AST_NODE_TYPES.Identifier) return false;
9152
+ return parameter.properties.some((property) => property.type === import_utils51.AST_NODE_TYPES.Property && import_utils51.ASTUtils.getPropertyName(property) === "skip" && property.value.type === import_utils51.AST_NODE_TYPES.Identifier && isStableParameterReference(callee, property.value, context, false));
9153
+ }
9154
+ function testContextParameter(owner, context) {
9155
+ const call = directArgumentCall(owner);
9156
+ if (call === null) return null;
9157
+ const root = testRoot(call.callee);
9158
+ if (root === null) return null;
9159
+ const binding = import_utils51.ASTUtils.findVariable(context.sourceCode.getScope(root), root.name);
9160
+ const definition = binding?.defs[0];
9161
+ const runnerGlobal = binding === null || binding.defs.length === 0;
9162
+ const vitestImport = binding?.defs.length === 1 && definition?.node.type === import_utils51.AST_NODE_TYPES.ImportSpecifier && definition.node.parent.type === import_utils51.AST_NODE_TYPES.ImportDeclaration && definition.node.parent.source.value === "vitest";
9163
+ if (!runnerGlobal && !vitestImport) return null;
9164
+ const position = contextParameterPosition(call.callee);
9165
+ return position === null ? null : owner.params[position] ?? null;
9166
+ }
9167
+ function contextParameterPosition(callee) {
9168
+ callee = unwrapExpression(callee);
9169
+ if (callee.type === import_utils51.AST_NODE_TYPES.Identifier) return 0;
9170
+ if (callee.type === import_utils51.AST_NODE_TYPES.MemberExpression) return contextParameterPosition(callee.object);
9171
+ if (callee.type !== import_utils51.AST_NODE_TYPES.CallExpression && callee.type !== import_utils51.AST_NODE_TYPES.TaggedTemplateExpression) return null;
9172
+ const builder = unwrapExpression(callee.type === import_utils51.AST_NODE_TYPES.CallExpression ? callee.callee : callee.tag);
9173
+ if (builder.type !== import_utils51.AST_NODE_TYPES.MemberExpression) return null;
9174
+ const name = staticMemberName3(builder);
9175
+ const inherited = contextParameterPosition(builder.object);
9176
+ return name === "each" || inherited === null ? null : name === "for" ? 1 : inherited;
9177
+ }
9178
+ function isStableParameterReference(identifier, parameter, context, memberReceiver) {
9179
+ const source = context.sourceCode;
9180
+ const binding = import_utils51.ASTUtils.findVariable(source.getScope(parameter), parameter.name);
9181
+ if (binding === null || binding !== import_utils51.ASTUtils.findVariable(source.getScope(identifier), identifier.name)) return false;
9182
+ return binding.references.every((reference) => {
9183
+ if (reference.isWrite() && !reference.init) return false;
9184
+ if (!memberReceiver) return true;
9185
+ const receiver = outerExpression(reference.identifier);
9186
+ const member = receiver.parent;
9187
+ if (member?.type !== import_utils51.AST_NODE_TYPES.MemberExpression || member.object !== receiver) return true;
9188
+ const name = staticMemberName3(member);
9189
+ if (name !== null && name !== "skip") return true;
9190
+ const use = outerExpression(member);
9191
+ const parent = use.parent;
9192
+ return !(parent.type === import_utils51.AST_NODE_TYPES.AssignmentExpression && parent.left === use) && !(parent.type === import_utils51.AST_NODE_TYPES.UpdateExpression && parent.argument === use) && !(parent.type === import_utils51.AST_NODE_TYPES.UnaryExpression && parent.operator === "delete" && parent.argument === use);
9193
+ });
9194
+ }
9195
+ function hasDominatingExplicitSkip(node, owner, context) {
9126
9196
  const block = node.parent;
9127
9197
  if (block?.type !== import_utils51.AST_NODE_TYPES.BlockStatement) return false;
9128
9198
  return block.body.some(
9129
- (candidate2) => candidate2.range[1] <= node.range[0] && candidate2.type === import_utils51.AST_NODE_TYPES.ExpressionStatement && isExplicitSkip(candidate2.expression, context)
9199
+ (candidate2) => candidate2.range[1] <= node.range[0] && candidate2.type === import_utils51.AST_NODE_TYPES.ExpressionStatement && (isExplicitSkip(candidate2.expression, context) || isUnconditionalContextSkip(candidate2.expression, owner, context))
9130
9200
  );
9131
9201
  }
9132
9202
  var no_bare_return_from_test_catch_default = createRule({
@@ -9160,7 +9230,7 @@ var no_bare_return_from_test_catch_default = createRule({
9160
9230
  if (parameter?.type === import_utils51.AST_NODE_TYPES.Identifier && returnBlock?.type === import_utils51.AST_NODE_TYPES.BlockStatement) {
9161
9231
  const errorBinding = import_utils51.ASTUtils.findVariable(context.sourceCode.getScope(parameter), parameter.name);
9162
9232
  const assertedError = returnBlock.body.some((statement) => {
9163
- if (statement.range[1] >= node.range[0] || statement.type !== import_utils51.AST_NODE_TYPES.ExpressionStatement) return false;
9233
+ if (statement.range[1] > node.range[0] || statement.type !== import_utils51.AST_NODE_TYPES.ExpressionStatement) return false;
9164
9234
  const expression = statement.expression;
9165
9235
  if (expression.type !== import_utils51.AST_NODE_TYPES.CallExpression || !isAssertion(expression, context)) return false;
9166
9236
  const root = rootIdentifier2(expression.callee);
@@ -9173,7 +9243,7 @@ var no_bare_return_from_test_catch_default = createRule({
9173
9243
  });
9174
9244
  if (assertedError) return;
9175
9245
  }
9176
- if (hasDominatingExplicitSkip(node, context)) return;
9246
+ if (hasDominatingExplicitSkip(node, owner, context)) return;
9177
9247
  if (!walkOwnScope(owner.body, (current) => current.range[0] > node.range[1] && isAssertion(current, context))) return;
9178
9248
  context.report({ node, messageId: "bareReturnFromTestCatch" });
9179
9249
  }
@@ -10578,8 +10648,9 @@ var NO_RESTRICTED_LIBRARY_LOAD_DOCUMENTATION = {
10578
10648
  category: "architecture",
10579
10649
  limitations: ["Only literal dynamic imports, unshadowed CommonJS loads/resolution calls, and runtime TypeScript import-equals declarations are checked; erased type imports are excluded. A configured restriction list is required."],
10580
10650
  examples: [
10581
- { id: "static-import", title: "Static imports remain the static-import rule's responsibility", outcome: "no-match", files: [{ path: "src/client.ts", source: "import axios from 'axios';" }], focusPath: "src/client.ts", expectedCount: 0, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] },
10582
- { id: "runtime-load", title: "Do not load a restricted library at runtime", outcome: "match", files: [{ path: "src/client.ts", source: "const client = require('axios');" }], focusPath: "src/client.ts", expectedCount: 1, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] }
10651
+ { id: "configured-replacement", title: "Use the configured library replacement", outcome: "no-match", files: [{ path: "src/client.ts", source: "import ky from 'ky'; const client = ky;" }], focusPath: "src/client.ts", expectedCount: 0, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] },
10652
+ { id: "runtime-load", title: "Do not load a restricted library at runtime", outcome: "match", files: [{ path: "src/client.ts", source: "const client = require('axios');" }], focusPath: "src/client.ts", expectedCount: 1, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] },
10653
+ { id: "static-import-owned-upstream", title: "Leave static restrictions to the owning import rule", outcome: "no-match", files: [{ path: "src/client.ts", source: "import axios from 'axios';" }], focusPath: "src/client.ts", expectedCount: 0, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] }
10583
10654
  ]
10584
10655
  };
10585
10656
  function staticModule(node) {
@@ -13305,7 +13376,7 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13305
13376
  remediation: "Assert on a value produced by the behavior under test against an independently specified expectation, or remove only the redundant assertion while preserving coverage.",
13306
13377
  category: "testing",
13307
13378
  limitations: [
13308
- "Only direct supported `expect` matcher calls in recognized test files are inspected. Local expect bindings, regular expressions, and unsupported coercions are excluded; failing constant assertions are not tautologies.",
13379
+ "Only supported `expect` matcher calls in recognized test files are inspected, including imported aliases and a single `.not` on zero-argument matchers. Local or type-only bindings, regular expressions, async modifiers and unsupported coercions are excluded; failing constant assertions are not tautologies.",
13309
13380
  "Scalar const bindings and aliases resolve through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded.",
13310
13381
  "Expected-side runtime dependence, including a complete inline snapshot computed from its actual value, remains manual review; independent static interpolation and intentional partial masking are valid."
13311
13382
  ],
@@ -13338,6 +13409,26 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13338
13409
  expectedCount: 1,
13339
13410
  public: true
13340
13411
  },
13412
+ {
13413
+ id: "negated-setup-oracle",
13414
+ scenarioId: "negated-setup",
13415
+ title: "Negating a fixed matcher still does not observe the application",
13416
+ outcome: "match",
13417
+ files: [{ path: "src/service.test.ts", source: "import {expect as verify} from 'vitest'; it('works', () => { const ready = false; verify(ready).not.toBeTruthy(); });" }],
13418
+ focusPath: "src/service.test.ts",
13419
+ expectedCount: 1,
13420
+ public: true
13421
+ },
13422
+ {
13423
+ id: "negated-produced-oracle",
13424
+ scenarioId: "negated-setup",
13425
+ title: "A negated assertion can observe a runtime result",
13426
+ outcome: "no-match",
13427
+ files: [{ path: "src/service.test.ts", source: "import {expect as verify} from 'vitest'; it('works', () => { verify(serviceStatus()).not.toBeNull(); });" }],
13428
+ focusPath: "src/service.test.ts",
13429
+ expectedCount: 0,
13430
+ public: true
13431
+ },
13341
13432
  {
13342
13433
  id: "produced-scalar-oracle",
13343
13434
  scenarioId: "setup-only",
@@ -13431,13 +13522,16 @@ function passesZeroArgumentMatcher(node, matcher) {
13431
13522
  return false;
13432
13523
  }
13433
13524
  }
13434
- function expectOperand(callee) {
13435
- const receiver = unwrapExpression(callee.object);
13525
+ function expectOperand(callee, sourceCode) {
13526
+ let receiver = unwrapExpression(callee.object);
13527
+ const negated = receiver.type === import_utils74.AST_NODE_TYPES.MemberExpression && import_utils74.ASTUtils.getPropertyName(receiver) === "not";
13528
+ if (negated && receiver.type === import_utils74.AST_NODE_TYPES.MemberExpression) receiver = unwrapExpression(receiver.object);
13436
13529
  const expectCallee = receiver.type === import_utils74.AST_NODE_TYPES.CallExpression ? unwrapExpression(receiver.callee) : null;
13437
- if (receiver.type !== import_utils74.AST_NODE_TYPES.CallExpression || expectCallee?.type !== import_utils74.AST_NODE_TYPES.Identifier || expectCallee.name !== "expect" || receiver.arguments.length !== 1) {
13530
+ if (receiver.type !== import_utils74.AST_NODE_TYPES.CallExpression || expectCallee?.type !== import_utils74.AST_NODE_TYPES.Identifier || runtimeTestFrameworkName(sourceCode, expectCallee, "assertion") !== "expect" || receiver.arguments.length !== 1) {
13438
13531
  return null;
13439
13532
  }
13440
- return receiver.arguments[0] ?? null;
13533
+ const operand = receiver.arguments[0];
13534
+ return operand === void 0 ? null : { operand, negated };
13441
13535
  }
13442
13536
  var no_tautological_expect_default = createRule({
13443
13537
  name: "no-tautological-expect",
@@ -13468,33 +13562,25 @@ var no_tautological_expect_default = createRule({
13468
13562
  if (callee.type !== import_utils74.AST_NODE_TYPES.MemberExpression) return;
13469
13563
  const matcher = import_utils74.ASTUtils.getPropertyName(callee);
13470
13564
  if (matcher === null) return;
13471
- const receiver = unwrapExpression(callee.object);
13472
- if (receiver.type !== import_utils74.AST_NODE_TYPES.CallExpression) return;
13473
- const expectIdentifier = unwrapExpression(receiver.callee);
13474
- if (expectIdentifier.type !== import_utils74.AST_NODE_TYPES.Identifier) return;
13475
- const variable = import_utils74.ASTUtils.findVariable(context.sourceCode.getScope(expectIdentifier), expectIdentifier.name);
13476
- if (variable !== null && variable.defs.some((definition) => {
13477
- if (definition.node.type !== import_utils74.AST_NODE_TYPES.ImportSpecifier) return true;
13478
- const declaration = definition.node.parent;
13479
- const imported = definition.node.imported;
13480
- return declaration.type !== import_utils74.AST_NODE_TYPES.ImportDeclaration || !["vitest", "@jest/globals", "@playwright/test", "bun:test"].includes(String(declaration.source.value)) || (imported.type === import_utils74.AST_NODE_TYPES.Identifier ? imported.name : imported.value) !== "expect";
13481
- })) return;
13482
- const writtenOperand = expectOperand(callee);
13483
- const operand = writtenOperand === null ? null : resolveLiteral(writtenOperand, context.sourceCode);
13565
+ const assertion = expectOperand(callee, context.sourceCode);
13566
+ if (assertion === null) return;
13567
+ const writtenOperand = assertion.operand;
13568
+ const operand = resolveLiteral(writtenOperand, context.sourceCode);
13484
13569
  if (operand === null) {
13485
13570
  return;
13486
13571
  }
13487
- if (ZERO_ARG_MATCHERS.has(matcher) && node.arguments.length === 0 && passesZeroArgumentMatcher(operand, matcher)) {
13572
+ const writtenMatcher = `${assertion.negated ? "not." : ""}${matcher}`;
13573
+ if (ZERO_ARG_MATCHERS.has(matcher) && node.arguments.length === 0 && passesZeroArgumentMatcher(operand, matcher) !== assertion.negated) {
13488
13574
  context.report({
13489
13575
  node,
13490
13576
  messageId: "tautologicalMatcher",
13491
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13577
+ data: { operand: preview2(writtenOperand), matcher: writtenMatcher }
13492
13578
  });
13493
13579
  return;
13494
13580
  }
13495
13581
  const writtenExpected = node.arguments[0];
13496
13582
  const expected = writtenExpected === void 0 ? null : resolveLiteral(writtenExpected, context.sourceCode);
13497
- if (!EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === null) {
13583
+ if (assertion.negated || !EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === null) {
13498
13584
  return;
13499
13585
  }
13500
13586
  if (matcher === "toBe" && (isStructuralLiteral(operand) || isStructuralLiteral(expected))) {
@@ -13506,7 +13592,7 @@ var no_tautological_expect_default = createRule({
13506
13592
  context.report({
13507
13593
  node,
13508
13594
  messageId: "tautologicalComparison",
13509
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13595
+ data: { operand: preview2(writtenOperand), matcher }
13510
13596
  });
13511
13597
  }
13512
13598
  };
@@ -14084,7 +14170,7 @@ var NO_DECLARATION_COMMENT_WALL_DOCUMENTATION = {
14084
14170
  id: "uncommented-members",
14085
14171
  title: "Let clear member names stand alone",
14086
14172
  outcome: "no-match",
14087
- files: [{ path: "src/status.ts", source: "enum Status { Pending = 'pending', Done = 'done', Failed = 'failed' }" }],
14173
+ files: [{ path: "src/status.ts", source: "enum Status { Pending = 'pending', Finished = 'finished', Failed = 'failed' }" }],
14088
14174
  focusPath: "src/status.ts",
14089
14175
  expectedCount: 0,
14090
14176
  public: true
@@ -14375,7 +14461,7 @@ var NO_TYPE_MEMBER_COMMENT_WALL_DOCUMENTATION = {
14375
14461
  id: "uncommented-members",
14376
14462
  title: "Let clear member names and types stand alone",
14377
14463
  outcome: "no-match",
14378
- files: [{ path: "src/credentials.ts", source: "interface Credentials { host: string; port: number; username: string; }" }],
14464
+ files: [{ path: "src/credentials.ts", source: "interface Credentials { host?: string; port?: number; username?: string; password?: string; }" }],
14379
14465
  focusPath: "src/credentials.ts",
14380
14466
  expectedCount: 0,
14381
14467
  public: true
@@ -14964,12 +15050,37 @@ var NO_ZOD_NATIVE_ENUM_DOCUMENTATION = {
14964
15050
  },
14965
15051
  {
14966
15052
  id: "zod-native-enum",
14967
- title: "Do not wrap a TypeScript enum",
15053
+ title: "Declare literal values instead of an enum-like object",
14968
15054
  outcome: "match",
14969
15055
  files: [{ path: "src/status.ts", source: 'import { z } from "zod"; const S = z.nativeEnum({ Active: "active", Inactive: "inactive" });' }],
14970
15056
  focusPath: "src/status.ts",
14971
15057
  expectedCount: 1,
14972
15058
  public: true
15059
+ },
15060
+ {
15061
+ id: "imported-typescript-enum",
15062
+ scenarioId: "imported-enum",
15063
+ title: "Do not hide an imported TypeScript enum behind z.enum",
15064
+ outcome: "match",
15065
+ files: [
15066
+ { path: "src/status-values.ts", source: "export enum Status { Active = 'active', Inactive = 'inactive' }" },
15067
+ { path: "src/status.ts", source: "import { z } from 'zod'; import { Status } from './status-values.js';\nexport const StatusSchema = z.enum(Status);" }
15068
+ ],
15069
+ installedDependencies: [{ module: "zod", version: "4.6.7" }],
15070
+ focusPath: "src/status.ts",
15071
+ expectedCount: 1,
15072
+ public: true
15073
+ },
15074
+ {
15075
+ id: "schema-owned-string-values",
15076
+ scenarioId: "imported-enum",
15077
+ title: "Let the schema own its literal value domain",
15078
+ outcome: "no-match",
15079
+ files: [{ path: "src/status.ts", source: "import { z } from 'zod';\nexport const StatusSchema = z.enum(['active', 'inactive']);" }],
15080
+ installedDependencies: [{ module: "zod", version: "4.6.7" }],
15081
+ focusPath: "src/status.ts",
15082
+ expectedCount: 0,
15083
+ public: true
14973
15084
  }
14974
15085
  ]
14975
15086
  };
@@ -17882,7 +17993,7 @@ var PREFER_MODULE_LEVEL_REFINED_SCHEMA_DOCUMENTATION = {
17882
17993
  outcome: "no-match",
17883
17994
  files: [{
17884
17995
  path: "src/options.ts",
17885
- source: "import { z } from 'zod'; const BatchSizeSchema = z.number().int().min(1).max(1000); export function parse(value: unknown) { return BatchSizeSchema.parse(value); }"
17996
+ source: "import { z } from 'zod'; const NameSchema = z.string().trim().min(1).max(128); export function parse(value: unknown) { return NameSchema.parse(value); }"
17886
17997
  }],
17887
17998
  focusPath: "src/options.ts",
17888
17999
  expectedCount: 0,
@@ -19517,7 +19628,7 @@ var PREFER_SCHEMA_FOR_API_PAYLOAD_DOCUMENTATION = {
19517
19628
  "Test fixtures, generated clients and recognized local-file reads are excluded. This is bounded local analysis, not a general control-flow or mutation proof."
19518
19629
  ],
19519
19630
  examples: [
19520
- { id: "validated-payload", title: "Validate before property access", outcome: "no-match", files: [{ path: "src/client.ts", source: "async function load(response: Response) { const body = UserSchema.parse(await response.json()); return body.id; }" }], focusPath: "src/client.ts", expectedCount: 0, public: true },
19631
+ { id: "validated-payload", title: "Validate before property access", outcome: "no-match", files: [{ path: "src/client.ts", source: "import { z } from 'zod';\nconst UserSchema = z.object({ id: z.string() });\nasync function load(response: Response) { const body = UserSchema.parse(await response.json()); return body.id; }" }], installedDependencies: [{ module: "zod", version: "4.6.7" }], focusPath: "src/client.ts", expectedCount: 0, public: true },
19521
19632
  { id: "unvalidated-payload", title: "Do not trust response JSON directly", outcome: "match", files: [{ path: "src/client.ts", source: "async function load(response: Response) { const body = await response.json(); return body.id; }" }], focusPath: "src/client.ts", expectedCount: 1, public: true }
19522
19633
  ]
19523
19634
  };
@@ -20193,8 +20304,8 @@ var PREFER_SWITCH_FOR_REPEATED_EQUALITY_DOCUMENTATION = {
20193
20304
  "The rule deliberately ignores compound predicates, loose equality, ranges, and chains that compare different discriminants."
20194
20305
  ],
20195
20306
  examples: [
20196
- { id: "switch-dispatch", title: "Make finite dispatch explicit", outcome: "no-match", files: [{ path: "src/render.ts", source: "switch (kind) { case 'a': return a(); case 'b': return b(); case 'c': return c(); default: return fallback(); }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
20197
- { id: "repeated-equality", title: "Avoid repeating the discriminant", outcome: "match", files: [{ path: "src/render.ts", source: "function render(kind: string) { if (kind === 'a') return a(); else if (kind === 'b') return b(); else if (kind === 'c') return c(); }" }], focusPath: "src/render.ts", expectedCount: 1, public: true },
20307
+ { id: "switch-dispatch", title: "Make finite dispatch explicit", outcome: "no-match", files: [{ path: "src/render.ts", source: "function render(kind: string) { switch (kind) { case 'a': return 'alpha'; case 'b': return 'beta'; case 'c': return 'gamma'; default: return 'unknown'; } }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
20308
+ { id: "repeated-equality", title: "Avoid repeating the discriminant", outcome: "match", files: [{ path: "src/render.ts", source: "function render(kind: string) { if (kind === 'a') return 'alpha'; else if (kind === 'b') return 'beta'; else if (kind === 'c') return 'gamma'; return 'unknown'; }" }], focusPath: "src/render.ts", expectedCount: 1, public: true },
20198
20309
  { id: "escaped-middle", scenarioId: "native-discriminant", title: "Use native identifier identity", outcome: "match", files: [{ path: "src/dispatch.ts", source: `function probe(kind){if(kind==='a')return 1;else if(\\u006bind==='b')return 2;else if(kind==='c')return 3;return 0;}
20199
20310
  console.log(JSON.stringify(["a","b","c","d"].map(probe)));` }], focusPath: "src/dispatch.ts", expectedCount: 1, public: true },
20200
20311
  { id: "different-discriminants", scenarioId: "native-discriminant", title: "Use native identifier identity", outcome: "no-match", files: [{ path: "src/dispatch.ts", source: `function probe(kind){const other="other";if(kind==='a')return 1;else if(other==='b')return 2;else if(kind==='c')return 3;return 0;}
@@ -22732,7 +22843,7 @@ var REQUIRE_ASSERT_NEVER_DOCUMENTATION = {
22732
22843
  category: "correctness",
22733
22844
  limitations: ["Requires type information and singleton case values covering the current union. The helper must accept never to provide a compile-time check; its spelling alone is not a proof of that contract. Review the desired runtime behavior for unexpected external values."],
22734
22845
  examples: [
22735
- { id: "assert-never-default", title: "Make the default exhaustive", outcome: "no-match", files: [{ path: "src/render.ts", source: "declare const kind: 'a' | 'b';\nswitch (kind) { case 'a': break; case 'b': break; default: assertNever(kind); }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
22846
+ { id: "assert-never-default", title: "Make the default exhaustive", outcome: "no-match", files: [{ path: "src/render.ts", source: "function assertNever(value: never): never { throw new Error(`Unexpected kind: ${String(value)}`); }\ndeclare const kind: 'a' | 'b';\nswitch (kind) { case 'a': break; case 'b': break; default: assertNever(kind); }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
22736
22847
  { id: "empty-default", title: "Do not leave an exhaustive default empty", outcome: "match", files: [{ path: "src/render.ts", source: "declare const kind: 'a' | 'b';\nswitch (kind) { case 'a': break; case 'b': break; default: }" }], focusPath: "src/render.ts", expectedCount: 1, public: true }
22737
22848
  ]
22738
22849
  };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // package.json
2
2
  var package_default = {
3
3
  name: "@sarj/eslint-plugin",
4
- version: "16.15.0",
4
+ version: "16.15.2",
5
5
  packageManager: "npm@12.2.0",
6
6
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
7
7
  type: "module",
@@ -3867,10 +3867,13 @@ var NO_CLIENT_SIDE_DATA_FETCHING_DOCUMENTATION = {
3867
3867
  examples: [
3868
3868
  {
3869
3869
  id: "effect-without-fetch",
3870
- title: "An effect performs no data request",
3870
+ title: "Load data while rendering on the server",
3871
3871
  outcome: "no-match",
3872
- files: [{ path: "src/users.tsx", source: "import { useEffect } from 'react'; useEffect(() => { console.log('mounted'); }, []);" }],
3873
- focusPath: "src/users.tsx",
3872
+ files: [
3873
+ { path: "app/users/page.tsx", source: "import { loadUsers } from './users-client';\nexport default async function Users() { const users = await loadUsers(); return <pre>{JSON.stringify(users)}</pre>; }" },
3874
+ { path: "app/users/users-client.ts", source: "export async function loadUsers(): Promise<unknown> { const response = await fetch('https://api.example.com/users'); return response.json(); }" }
3875
+ ],
3876
+ focusPath: "app/users/page.tsx",
3874
3877
  expectedCount: 0,
3875
3878
  public: true
3876
3879
  },
@@ -3878,7 +3881,7 @@ var NO_CLIENT_SIDE_DATA_FETCHING_DOCUMENTATION = {
3878
3881
  id: "fetch-inside-effect",
3879
3882
  title: "An effect starts a data request",
3880
3883
  outcome: "match",
3881
- files: [{ path: "src/users.tsx", source: "useEffect(() => { fetch('/api/users'); }, []);" }],
3884
+ files: [{ path: "src/users.tsx", source: "'use client'; import { useEffect } from 'react';\nexport function Users() { useEffect(() => { fetch('/api/users'); }, []); return null; }" }],
3882
3885
  focusPath: "src/users.tsx",
3883
3886
  expectedCount: 1,
3884
3887
  public: true
@@ -6019,11 +6022,11 @@ var NO_FAT_TRY_BLOCKS_DOCUMENTATION = {
6019
6022
  examples: [
6020
6023
  {
6021
6024
  id: "focused-try-block",
6022
- title: "Three selected operations stay within the default threshold",
6025
+ title: "Keep only operations with the same recovery policy in the try block",
6023
6026
  outcome: "no-match",
6024
6027
  files: [{
6025
6028
  path: "src/load.ts",
6026
- source: "function f() { try { const a = one(); const b = two(); const c = three(); } catch (error) { handle(error); } finish(); }"
6029
+ source: "function f() { try { const a = one(); const b = two(); const c = three(); } catch (error) { handle(error); } const d = four(); finish(); }"
6027
6030
  }],
6028
6031
  focusPath: "src/load.ts",
6029
6032
  expectedCount: 0,
@@ -7818,7 +7821,7 @@ var NO_JSON_STRINGIFY_OBJECT_EQUALITY_DOCUMENTATION = {
7818
7821
  id: "domain-comparator",
7819
7822
  title: "Use an explicit structural comparator",
7820
7823
  outcome: "no-match",
7821
- files: [{ path: "src/compare.ts", source: "const same = sameRecord(actual, expected);" }],
7824
+ files: [{ path: "src/compare.ts", source: "type RecordState = { id: string; state: string };\nfunction sameRecord(actual: RecordState, expected: RecordState): boolean { return actual.id === expected.id && actual.state === expected.state; }\nconst same = sameRecord({ id: '1', state: 'ready' }, { state: 'ready', id: '1' });" }],
7822
7825
  focusPath: "src/compare.ts",
7823
7826
  expectedCount: 0,
7824
7827
  public: true
@@ -9049,14 +9052,19 @@ var NO_BARE_RETURN_FROM_TEST_CATCH_DOCUMENTATION = {
9049
9052
  remediation: "Rethrow the error, assert on it, or use the runner's explicit skip mechanism when the capability is optional.",
9050
9053
  category: "testing",
9051
9054
  filePatterns: ["**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**"],
9052
- limitations: ["Only bare returns owned by a direct supported test callback and followed lexically by a framework assertion are reported. A runner skip suppresses the finding only when it is an unconditional earlier statement in the return's block."],
9055
+ limitations: ["Only bare returns owned by an inline supported test callback and followed lexically by a framework assertion are reported, including each/for tables and runIf/skipIf builders. Custom builders and indirect callback references are excluded. A runner skip suppresses the finding only when it is an unconditional earlier statement in the return's block. Vitest imports and unshadowed runner globals support TestContext.skip on the first ordinary-test parameter or second for-table parameter, including a directly destructured skip alias; each-table data arguments, reassigned bindings, overwritten skip methods and dynamic skip conditions are excluded."],
9053
9056
  examples: [
9054
9057
  { id: "rethrow", title: "Preserve the failure", outcome: "no-match", files: [{ path: "src/codec.test.ts", source: "test('decodes', () => { try { decode(); } catch (error) { throw error; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 0, public: true },
9055
- { id: "bare-return", title: "Do not silently pass", outcome: "match", files: [{ path: "src/codec.test.ts", source: "test('decodes', () => { try { decode(); } catch { return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true }
9058
+ { id: "bare-return", title: "Do not silently pass", outcome: "match", files: [{ path: "src/codec.test.ts", source: "test('decodes', () => { try { decode(); } catch { return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true },
9059
+ { id: "table-bare-return", scenarioId: "table-catch", title: "Case tables must preserve failures", outcome: "match", files: [{ path: "src/codec.test.ts", source: "test.each(['valid', 'invalid'])('decodes %s', (input) => { try { decode(input); } catch { return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true },
9060
+ { id: "table-rethrow", scenarioId: "table-catch", title: "Each table case propagates its failure", outcome: "no-match", files: [{ path: "src/codec.test.ts", source: "test.each(['valid', 'invalid'])('decodes %s', (input) => { try { decode(input); } catch (error) { throw error; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 0, public: true },
9061
+ { id: "table-context-skip", scenarioId: "context-skip", title: "A Vitest context skip preserves an optional table case", outcome: "no-match", files: [{ path: "src/codec.test.ts", source: "import {test, expect} from 'vitest'; test.for(['optional'])('decodes %s', (input, context) => { try { decode(input); } catch { context.skip('codec unavailable'); return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 0, public: true },
9062
+ { id: "table-context-false-skip", scenarioId: "context-skip", title: "A false skip condition does not preserve the failure", outcome: "match", files: [{ path: "src/codec.test.ts", source: "import {test, expect} from 'vitest'; test.for(['optional'])('decodes %s', (input, context) => { try { decode(input); } catch { context.skip(false); return; } expect(result()).toBe('ok'); });" }], focusPath: "src/codec.test.ts", expectedCount: 1, public: true }
9056
9063
  ]
9057
9064
  };
9058
9065
  var TEST_NAMES = /* @__PURE__ */ new Set(["it", "test"]);
9059
9066
  var TEST_MODIFIERS2 = /* @__PURE__ */ new Set(["concurrent", "fails", "only", "sequential", "skip"]);
9067
+ var TEST_BUILDERS = /* @__PURE__ */ new Set(["each", "for", "runIf", "skipIf"]);
9060
9068
  var ASSERTION_NAMES = /* @__PURE__ */ new Set(["assert", "assertType", "expect", "expectTypeOf"]);
9061
9069
  var FUNCTION_TYPES3 = /* @__PURE__ */ new Set([AST_NODE_TYPES43.ArrowFunctionExpression, AST_NODE_TYPES43.FunctionExpression, AST_NODE_TYPES43.FunctionDeclaration]);
9062
9070
  function staticMemberName3(node) {
@@ -9080,6 +9088,12 @@ function isDirectTestCallback(node, context) {
9080
9088
  function testRoot(callee) {
9081
9089
  callee = unwrapExpression(callee);
9082
9090
  if (callee.type === AST_NODE_TYPES43.Identifier) return callee;
9091
+ if (callee.type === AST_NODE_TYPES43.CallExpression || callee.type === AST_NODE_TYPES43.TaggedTemplateExpression) {
9092
+ const builder = unwrapExpression(callee.type === AST_NODE_TYPES43.CallExpression ? callee.callee : callee.tag);
9093
+ if (builder.type !== AST_NODE_TYPES43.MemberExpression) return null;
9094
+ const name = staticMemberName3(builder);
9095
+ return name !== null && (callee.type === AST_NODE_TYPES43.TaggedTemplateExpression ? name === "each" || name === "for" : TEST_BUILDERS.has(name)) ? testRoot(builder.object) : null;
9096
+ }
9083
9097
  if (callee.type !== AST_NODE_TYPES43.MemberExpression) return null;
9084
9098
  const modifier = staticMemberName3(callee);
9085
9099
  return modifier !== null && TEST_MODIFIERS2.has(modifier) ? testRoot(callee.object) : null;
@@ -9103,11 +9117,67 @@ function isExplicitSkip(node, context) {
9103
9117
  const root = rootIdentifier2(unwrappedNodeCallee.object);
9104
9118
  return root !== null && TEST_NAMES.has(runtimeTestFrameworkName(context.sourceCode, root, "test") ?? "");
9105
9119
  }
9106
- function hasDominatingExplicitSkip(node, context) {
9120
+ function isUnconditionalContextSkip(node, owner, context) {
9121
+ if (node.type !== AST_NODE_TYPES43.CallExpression || node.optional) return false;
9122
+ const first = node.arguments[0] === void 0 ? void 0 : unwrapExpression(node.arguments[0]);
9123
+ const note = first?.type === AST_NODE_TYPES43.Literal && typeof first.value === "string" || first?.type === AST_NODE_TYPES43.TemplateLiteral && first.expressions.length === 0;
9124
+ const always = first?.type === AST_NODE_TYPES43.Literal && first.value === true;
9125
+ if (node.arguments.length !== 0 && !(node.arguments.length === 1 && note) && !(node.arguments.length <= 2 && always)) return false;
9126
+ const parameter = testContextParameter(owner, context);
9127
+ const callee = unwrapExpression(node.callee);
9128
+ if (parameter?.type === AST_NODE_TYPES43.Identifier && callee.type === AST_NODE_TYPES43.MemberExpression && !callee.optional && staticMemberName3(callee) === "skip") {
9129
+ const receiver = unwrapExpression(callee.object);
9130
+ return receiver.type === AST_NODE_TYPES43.Identifier && isStableParameterReference(receiver, parameter, context, true);
9131
+ }
9132
+ if (parameter?.type !== AST_NODE_TYPES43.ObjectPattern || callee.type !== AST_NODE_TYPES43.Identifier) return false;
9133
+ return parameter.properties.some((property) => property.type === AST_NODE_TYPES43.Property && ASTUtils22.getPropertyName(property) === "skip" && property.value.type === AST_NODE_TYPES43.Identifier && isStableParameterReference(callee, property.value, context, false));
9134
+ }
9135
+ function testContextParameter(owner, context) {
9136
+ const call = directArgumentCall(owner);
9137
+ if (call === null) return null;
9138
+ const root = testRoot(call.callee);
9139
+ if (root === null) return null;
9140
+ const binding = ASTUtils22.findVariable(context.sourceCode.getScope(root), root.name);
9141
+ const definition = binding?.defs[0];
9142
+ const runnerGlobal = binding === null || binding.defs.length === 0;
9143
+ const vitestImport = binding?.defs.length === 1 && definition?.node.type === AST_NODE_TYPES43.ImportSpecifier && definition.node.parent.type === AST_NODE_TYPES43.ImportDeclaration && definition.node.parent.source.value === "vitest";
9144
+ if (!runnerGlobal && !vitestImport) return null;
9145
+ const position = contextParameterPosition(call.callee);
9146
+ return position === null ? null : owner.params[position] ?? null;
9147
+ }
9148
+ function contextParameterPosition(callee) {
9149
+ callee = unwrapExpression(callee);
9150
+ if (callee.type === AST_NODE_TYPES43.Identifier) return 0;
9151
+ if (callee.type === AST_NODE_TYPES43.MemberExpression) return contextParameterPosition(callee.object);
9152
+ if (callee.type !== AST_NODE_TYPES43.CallExpression && callee.type !== AST_NODE_TYPES43.TaggedTemplateExpression) return null;
9153
+ const builder = unwrapExpression(callee.type === AST_NODE_TYPES43.CallExpression ? callee.callee : callee.tag);
9154
+ if (builder.type !== AST_NODE_TYPES43.MemberExpression) return null;
9155
+ const name = staticMemberName3(builder);
9156
+ const inherited = contextParameterPosition(builder.object);
9157
+ return name === "each" || inherited === null ? null : name === "for" ? 1 : inherited;
9158
+ }
9159
+ function isStableParameterReference(identifier, parameter, context, memberReceiver) {
9160
+ const source = context.sourceCode;
9161
+ const binding = ASTUtils22.findVariable(source.getScope(parameter), parameter.name);
9162
+ if (binding === null || binding !== ASTUtils22.findVariable(source.getScope(identifier), identifier.name)) return false;
9163
+ return binding.references.every((reference) => {
9164
+ if (reference.isWrite() && !reference.init) return false;
9165
+ if (!memberReceiver) return true;
9166
+ const receiver = outerExpression(reference.identifier);
9167
+ const member = receiver.parent;
9168
+ if (member?.type !== AST_NODE_TYPES43.MemberExpression || member.object !== receiver) return true;
9169
+ const name = staticMemberName3(member);
9170
+ if (name !== null && name !== "skip") return true;
9171
+ const use = outerExpression(member);
9172
+ const parent = use.parent;
9173
+ return !(parent.type === AST_NODE_TYPES43.AssignmentExpression && parent.left === use) && !(parent.type === AST_NODE_TYPES43.UpdateExpression && parent.argument === use) && !(parent.type === AST_NODE_TYPES43.UnaryExpression && parent.operator === "delete" && parent.argument === use);
9174
+ });
9175
+ }
9176
+ function hasDominatingExplicitSkip(node, owner, context) {
9107
9177
  const block = node.parent;
9108
9178
  if (block?.type !== AST_NODE_TYPES43.BlockStatement) return false;
9109
9179
  return block.body.some(
9110
- (candidate2) => candidate2.range[1] <= node.range[0] && candidate2.type === AST_NODE_TYPES43.ExpressionStatement && isExplicitSkip(candidate2.expression, context)
9180
+ (candidate2) => candidate2.range[1] <= node.range[0] && candidate2.type === AST_NODE_TYPES43.ExpressionStatement && (isExplicitSkip(candidate2.expression, context) || isUnconditionalContextSkip(candidate2.expression, owner, context))
9111
9181
  );
9112
9182
  }
9113
9183
  var no_bare_return_from_test_catch_default = createRule({
@@ -9141,7 +9211,7 @@ var no_bare_return_from_test_catch_default = createRule({
9141
9211
  if (parameter?.type === AST_NODE_TYPES43.Identifier && returnBlock?.type === AST_NODE_TYPES43.BlockStatement) {
9142
9212
  const errorBinding = ASTUtils22.findVariable(context.sourceCode.getScope(parameter), parameter.name);
9143
9213
  const assertedError = returnBlock.body.some((statement) => {
9144
- if (statement.range[1] >= node.range[0] || statement.type !== AST_NODE_TYPES43.ExpressionStatement) return false;
9214
+ if (statement.range[1] > node.range[0] || statement.type !== AST_NODE_TYPES43.ExpressionStatement) return false;
9145
9215
  const expression = statement.expression;
9146
9216
  if (expression.type !== AST_NODE_TYPES43.CallExpression || !isAssertion(expression, context)) return false;
9147
9217
  const root = rootIdentifier2(expression.callee);
@@ -9154,7 +9224,7 @@ var no_bare_return_from_test_catch_default = createRule({
9154
9224
  });
9155
9225
  if (assertedError) return;
9156
9226
  }
9157
- if (hasDominatingExplicitSkip(node, context)) return;
9227
+ if (hasDominatingExplicitSkip(node, owner, context)) return;
9158
9228
  if (!walkOwnScope(owner.body, (current) => current.range[0] > node.range[1] && isAssertion(current, context))) return;
9159
9229
  context.report({ node, messageId: "bareReturnFromTestCatch" });
9160
9230
  }
@@ -10559,8 +10629,9 @@ var NO_RESTRICTED_LIBRARY_LOAD_DOCUMENTATION = {
10559
10629
  category: "architecture",
10560
10630
  limitations: ["Only literal dynamic imports, unshadowed CommonJS loads/resolution calls, and runtime TypeScript import-equals declarations are checked; erased type imports are excluded. A configured restriction list is required."],
10561
10631
  examples: [
10562
- { id: "static-import", title: "Static imports remain the static-import rule's responsibility", outcome: "no-match", files: [{ path: "src/client.ts", source: "import axios from 'axios';" }], focusPath: "src/client.ts", expectedCount: 0, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] },
10563
- { id: "runtime-load", title: "Do not load a restricted library at runtime", outcome: "match", files: [{ path: "src/client.ts", source: "const client = require('axios');" }], focusPath: "src/client.ts", expectedCount: 1, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] }
10632
+ { id: "configured-replacement", title: "Use the configured library replacement", outcome: "no-match", files: [{ path: "src/client.ts", source: "import ky from 'ky'; const client = ky;" }], focusPath: "src/client.ts", expectedCount: 0, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] },
10633
+ { id: "runtime-load", title: "Do not load a restricted library at runtime", outcome: "match", files: [{ path: "src/client.ts", source: "const client = require('axios');" }], focusPath: "src/client.ts", expectedCount: 1, public: true, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] },
10634
+ { id: "static-import-owned-upstream", title: "Leave static restrictions to the owning import rule", outcome: "no-match", files: [{ path: "src/client.ts", source: "import axios from 'axios';" }], focusPath: "src/client.ts", expectedCount: 0, ruleOptions: [{ libraries: [{ id: "LIB101", module: "axios", replacement: "Ky" }] }] }
10564
10635
  ]
10565
10636
  };
10566
10637
  function staticModule(node) {
@@ -13286,7 +13357,7 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13286
13357
  remediation: "Assert on a value produced by the behavior under test against an independently specified expectation, or remove only the redundant assertion while preserving coverage.",
13287
13358
  category: "testing",
13288
13359
  limitations: [
13289
- "Only direct supported `expect` matcher calls in recognized test files are inspected. Local expect bindings, regular expressions, and unsupported coercions are excluded; failing constant assertions are not tautologies.",
13360
+ "Only supported `expect` matcher calls in recognized test files are inspected, including imported aliases and a single `.not` on zero-argument matchers. Local or type-only bindings, regular expressions, async modifiers and unsupported coercions are excluded; failing constant assertions are not tautologies.",
13290
13361
  "Scalar const bindings and aliases resolve through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded.",
13291
13362
  "Expected-side runtime dependence, including a complete inline snapshot computed from its actual value, remains manual review; independent static interpolation and intentional partial masking are valid."
13292
13363
  ],
@@ -13319,6 +13390,26 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13319
13390
  expectedCount: 1,
13320
13391
  public: true
13321
13392
  },
13393
+ {
13394
+ id: "negated-setup-oracle",
13395
+ scenarioId: "negated-setup",
13396
+ title: "Negating a fixed matcher still does not observe the application",
13397
+ outcome: "match",
13398
+ files: [{ path: "src/service.test.ts", source: "import {expect as verify} from 'vitest'; it('works', () => { const ready = false; verify(ready).not.toBeTruthy(); });" }],
13399
+ focusPath: "src/service.test.ts",
13400
+ expectedCount: 1,
13401
+ public: true
13402
+ },
13403
+ {
13404
+ id: "negated-produced-oracle",
13405
+ scenarioId: "negated-setup",
13406
+ title: "A negated assertion can observe a runtime result",
13407
+ outcome: "no-match",
13408
+ files: [{ path: "src/service.test.ts", source: "import {expect as verify} from 'vitest'; it('works', () => { verify(serviceStatus()).not.toBeNull(); });" }],
13409
+ focusPath: "src/service.test.ts",
13410
+ expectedCount: 0,
13411
+ public: true
13412
+ },
13322
13413
  {
13323
13414
  id: "produced-scalar-oracle",
13324
13415
  scenarioId: "setup-only",
@@ -13412,13 +13503,16 @@ function passesZeroArgumentMatcher(node, matcher) {
13412
13503
  return false;
13413
13504
  }
13414
13505
  }
13415
- function expectOperand(callee) {
13416
- const receiver = unwrapExpression(callee.object);
13506
+ function expectOperand(callee, sourceCode) {
13507
+ let receiver = unwrapExpression(callee.object);
13508
+ const negated = receiver.type === AST_NODE_TYPES60.MemberExpression && ASTUtils36.getPropertyName(receiver) === "not";
13509
+ if (negated && receiver.type === AST_NODE_TYPES60.MemberExpression) receiver = unwrapExpression(receiver.object);
13417
13510
  const expectCallee = receiver.type === AST_NODE_TYPES60.CallExpression ? unwrapExpression(receiver.callee) : null;
13418
- if (receiver.type !== AST_NODE_TYPES60.CallExpression || expectCallee?.type !== AST_NODE_TYPES60.Identifier || expectCallee.name !== "expect" || receiver.arguments.length !== 1) {
13511
+ if (receiver.type !== AST_NODE_TYPES60.CallExpression || expectCallee?.type !== AST_NODE_TYPES60.Identifier || runtimeTestFrameworkName(sourceCode, expectCallee, "assertion") !== "expect" || receiver.arguments.length !== 1) {
13419
13512
  return null;
13420
13513
  }
13421
- return receiver.arguments[0] ?? null;
13514
+ const operand = receiver.arguments[0];
13515
+ return operand === void 0 ? null : { operand, negated };
13422
13516
  }
13423
13517
  var no_tautological_expect_default = createRule({
13424
13518
  name: "no-tautological-expect",
@@ -13449,33 +13543,25 @@ var no_tautological_expect_default = createRule({
13449
13543
  if (callee.type !== AST_NODE_TYPES60.MemberExpression) return;
13450
13544
  const matcher = ASTUtils36.getPropertyName(callee);
13451
13545
  if (matcher === null) return;
13452
- const receiver = unwrapExpression(callee.object);
13453
- if (receiver.type !== AST_NODE_TYPES60.CallExpression) return;
13454
- const expectIdentifier = unwrapExpression(receiver.callee);
13455
- if (expectIdentifier.type !== AST_NODE_TYPES60.Identifier) return;
13456
- const variable = ASTUtils36.findVariable(context.sourceCode.getScope(expectIdentifier), expectIdentifier.name);
13457
- if (variable !== null && variable.defs.some((definition) => {
13458
- if (definition.node.type !== AST_NODE_TYPES60.ImportSpecifier) return true;
13459
- const declaration = definition.node.parent;
13460
- const imported = definition.node.imported;
13461
- return declaration.type !== AST_NODE_TYPES60.ImportDeclaration || !["vitest", "@jest/globals", "@playwright/test", "bun:test"].includes(String(declaration.source.value)) || (imported.type === AST_NODE_TYPES60.Identifier ? imported.name : imported.value) !== "expect";
13462
- })) return;
13463
- const writtenOperand = expectOperand(callee);
13464
- const operand = writtenOperand === null ? null : resolveLiteral(writtenOperand, context.sourceCode);
13546
+ const assertion = expectOperand(callee, context.sourceCode);
13547
+ if (assertion === null) return;
13548
+ const writtenOperand = assertion.operand;
13549
+ const operand = resolveLiteral(writtenOperand, context.sourceCode);
13465
13550
  if (operand === null) {
13466
13551
  return;
13467
13552
  }
13468
- if (ZERO_ARG_MATCHERS.has(matcher) && node.arguments.length === 0 && passesZeroArgumentMatcher(operand, matcher)) {
13553
+ const writtenMatcher = `${assertion.negated ? "not." : ""}${matcher}`;
13554
+ if (ZERO_ARG_MATCHERS.has(matcher) && node.arguments.length === 0 && passesZeroArgumentMatcher(operand, matcher) !== assertion.negated) {
13469
13555
  context.report({
13470
13556
  node,
13471
13557
  messageId: "tautologicalMatcher",
13472
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13558
+ data: { operand: preview2(writtenOperand), matcher: writtenMatcher }
13473
13559
  });
13474
13560
  return;
13475
13561
  }
13476
13562
  const writtenExpected = node.arguments[0];
13477
13563
  const expected = writtenExpected === void 0 ? null : resolveLiteral(writtenExpected, context.sourceCode);
13478
- if (!EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === null) {
13564
+ if (assertion.negated || !EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === null) {
13479
13565
  return;
13480
13566
  }
13481
13567
  if (matcher === "toBe" && (isStructuralLiteral(operand) || isStructuralLiteral(expected))) {
@@ -13487,7 +13573,7 @@ var no_tautological_expect_default = createRule({
13487
13573
  context.report({
13488
13574
  node,
13489
13575
  messageId: "tautologicalComparison",
13490
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13576
+ data: { operand: preview2(writtenOperand), matcher }
13491
13577
  });
13492
13578
  }
13493
13579
  };
@@ -14065,7 +14151,7 @@ var NO_DECLARATION_COMMENT_WALL_DOCUMENTATION = {
14065
14151
  id: "uncommented-members",
14066
14152
  title: "Let clear member names stand alone",
14067
14153
  outcome: "no-match",
14068
- files: [{ path: "src/status.ts", source: "enum Status { Pending = 'pending', Done = 'done', Failed = 'failed' }" }],
14154
+ files: [{ path: "src/status.ts", source: "enum Status { Pending = 'pending', Finished = 'finished', Failed = 'failed' }" }],
14069
14155
  focusPath: "src/status.ts",
14070
14156
  expectedCount: 0,
14071
14157
  public: true
@@ -14356,7 +14442,7 @@ var NO_TYPE_MEMBER_COMMENT_WALL_DOCUMENTATION = {
14356
14442
  id: "uncommented-members",
14357
14443
  title: "Let clear member names and types stand alone",
14358
14444
  outcome: "no-match",
14359
- files: [{ path: "src/credentials.ts", source: "interface Credentials { host: string; port: number; username: string; }" }],
14445
+ files: [{ path: "src/credentials.ts", source: "interface Credentials { host?: string; port?: number; username?: string; password?: string; }" }],
14360
14446
  focusPath: "src/credentials.ts",
14361
14447
  expectedCount: 0,
14362
14448
  public: true
@@ -14952,12 +15038,37 @@ var NO_ZOD_NATIVE_ENUM_DOCUMENTATION = {
14952
15038
  },
14953
15039
  {
14954
15040
  id: "zod-native-enum",
14955
- title: "Do not wrap a TypeScript enum",
15041
+ title: "Declare literal values instead of an enum-like object",
14956
15042
  outcome: "match",
14957
15043
  files: [{ path: "src/status.ts", source: 'import { z } from "zod"; const S = z.nativeEnum({ Active: "active", Inactive: "inactive" });' }],
14958
15044
  focusPath: "src/status.ts",
14959
15045
  expectedCount: 1,
14960
15046
  public: true
15047
+ },
15048
+ {
15049
+ id: "imported-typescript-enum",
15050
+ scenarioId: "imported-enum",
15051
+ title: "Do not hide an imported TypeScript enum behind z.enum",
15052
+ outcome: "match",
15053
+ files: [
15054
+ { path: "src/status-values.ts", source: "export enum Status { Active = 'active', Inactive = 'inactive' }" },
15055
+ { path: "src/status.ts", source: "import { z } from 'zod'; import { Status } from './status-values.js';\nexport const StatusSchema = z.enum(Status);" }
15056
+ ],
15057
+ installedDependencies: [{ module: "zod", version: "4.6.7" }],
15058
+ focusPath: "src/status.ts",
15059
+ expectedCount: 1,
15060
+ public: true
15061
+ },
15062
+ {
15063
+ id: "schema-owned-string-values",
15064
+ scenarioId: "imported-enum",
15065
+ title: "Let the schema own its literal value domain",
15066
+ outcome: "no-match",
15067
+ files: [{ path: "src/status.ts", source: "import { z } from 'zod';\nexport const StatusSchema = z.enum(['active', 'inactive']);" }],
15068
+ installedDependencies: [{ module: "zod", version: "4.6.7" }],
15069
+ focusPath: "src/status.ts",
15070
+ expectedCount: 0,
15071
+ public: true
14961
15072
  }
14962
15073
  ]
14963
15074
  };
@@ -17879,7 +17990,7 @@ var PREFER_MODULE_LEVEL_REFINED_SCHEMA_DOCUMENTATION = {
17879
17990
  outcome: "no-match",
17880
17991
  files: [{
17881
17992
  path: "src/options.ts",
17882
- source: "import { z } from 'zod'; const BatchSizeSchema = z.number().int().min(1).max(1000); export function parse(value: unknown) { return BatchSizeSchema.parse(value); }"
17993
+ source: "import { z } from 'zod'; const NameSchema = z.string().trim().min(1).max(128); export function parse(value: unknown) { return NameSchema.parse(value); }"
17883
17994
  }],
17884
17995
  focusPath: "src/options.ts",
17885
17996
  expectedCount: 0,
@@ -19525,7 +19636,7 @@ var PREFER_SCHEMA_FOR_API_PAYLOAD_DOCUMENTATION = {
19525
19636
  "Test fixtures, generated clients and recognized local-file reads are excluded. This is bounded local analysis, not a general control-flow or mutation proof."
19526
19637
  ],
19527
19638
  examples: [
19528
- { id: "validated-payload", title: "Validate before property access", outcome: "no-match", files: [{ path: "src/client.ts", source: "async function load(response: Response) { const body = UserSchema.parse(await response.json()); return body.id; }" }], focusPath: "src/client.ts", expectedCount: 0, public: true },
19639
+ { id: "validated-payload", title: "Validate before property access", outcome: "no-match", files: [{ path: "src/client.ts", source: "import { z } from 'zod';\nconst UserSchema = z.object({ id: z.string() });\nasync function load(response: Response) { const body = UserSchema.parse(await response.json()); return body.id; }" }], installedDependencies: [{ module: "zod", version: "4.6.7" }], focusPath: "src/client.ts", expectedCount: 0, public: true },
19529
19640
  { id: "unvalidated-payload", title: "Do not trust response JSON directly", outcome: "match", files: [{ path: "src/client.ts", source: "async function load(response: Response) { const body = await response.json(); return body.id; }" }], focusPath: "src/client.ts", expectedCount: 1, public: true }
19530
19641
  ]
19531
19642
  };
@@ -20201,8 +20312,8 @@ var PREFER_SWITCH_FOR_REPEATED_EQUALITY_DOCUMENTATION = {
20201
20312
  "The rule deliberately ignores compound predicates, loose equality, ranges, and chains that compare different discriminants."
20202
20313
  ],
20203
20314
  examples: [
20204
- { id: "switch-dispatch", title: "Make finite dispatch explicit", outcome: "no-match", files: [{ path: "src/render.ts", source: "switch (kind) { case 'a': return a(); case 'b': return b(); case 'c': return c(); default: return fallback(); }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
20205
- { id: "repeated-equality", title: "Avoid repeating the discriminant", outcome: "match", files: [{ path: "src/render.ts", source: "function render(kind: string) { if (kind === 'a') return a(); else if (kind === 'b') return b(); else if (kind === 'c') return c(); }" }], focusPath: "src/render.ts", expectedCount: 1, public: true },
20315
+ { id: "switch-dispatch", title: "Make finite dispatch explicit", outcome: "no-match", files: [{ path: "src/render.ts", source: "function render(kind: string) { switch (kind) { case 'a': return 'alpha'; case 'b': return 'beta'; case 'c': return 'gamma'; default: return 'unknown'; } }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
20316
+ { id: "repeated-equality", title: "Avoid repeating the discriminant", outcome: "match", files: [{ path: "src/render.ts", source: "function render(kind: string) { if (kind === 'a') return 'alpha'; else if (kind === 'b') return 'beta'; else if (kind === 'c') return 'gamma'; return 'unknown'; }" }], focusPath: "src/render.ts", expectedCount: 1, public: true },
20206
20317
  { id: "escaped-middle", scenarioId: "native-discriminant", title: "Use native identifier identity", outcome: "match", files: [{ path: "src/dispatch.ts", source: `function probe(kind){if(kind==='a')return 1;else if(\\u006bind==='b')return 2;else if(kind==='c')return 3;return 0;}
20207
20318
  console.log(JSON.stringify(["a","b","c","d"].map(probe)));` }], focusPath: "src/dispatch.ts", expectedCount: 1, public: true },
20208
20319
  { id: "different-discriminants", scenarioId: "native-discriminant", title: "Use native identifier identity", outcome: "no-match", files: [{ path: "src/dispatch.ts", source: `function probe(kind){const other="other";if(kind==='a')return 1;else if(other==='b')return 2;else if(kind==='c')return 3;return 0;}
@@ -22747,7 +22858,7 @@ var REQUIRE_ASSERT_NEVER_DOCUMENTATION = {
22747
22858
  category: "correctness",
22748
22859
  limitations: ["Requires type information and singleton case values covering the current union. The helper must accept never to provide a compile-time check; its spelling alone is not a proof of that contract. Review the desired runtime behavior for unexpected external values."],
22749
22860
  examples: [
22750
- { id: "assert-never-default", title: "Make the default exhaustive", outcome: "no-match", files: [{ path: "src/render.ts", source: "declare const kind: 'a' | 'b';\nswitch (kind) { case 'a': break; case 'b': break; default: assertNever(kind); }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
22861
+ { id: "assert-never-default", title: "Make the default exhaustive", outcome: "no-match", files: [{ path: "src/render.ts", source: "function assertNever(value: never): never { throw new Error(`Unexpected kind: ${String(value)}`); }\ndeclare const kind: 'a' | 'b';\nswitch (kind) { case 'a': break; case 'b': break; default: assertNever(kind); }" }], focusPath: "src/render.ts", expectedCount: 0, public: true },
22751
22862
  { id: "empty-default", title: "Do not leave an exhaustive default empty", outcome: "match", files: [{ path: "src/render.ts", source: "declare const kind: 'a' | 'b';\nswitch (kind) { case 'a': break; case 'b': break; default: }" }], focusPath: "src/render.ts", expectedCount: 1, public: true }
22752
22863
  ]
22753
22864
  };
@@ -48,6 +48,7 @@ async function verifyRuleExamples(rule, setup = {}) {
48
48
  try {
49
49
  for (const file of example.files) {
50
50
  const path = fixturePath(root, file.path);
51
+ validateFixtureSyntax(example.id, file, path);
51
52
  await (0, import_promises.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
52
53
  await (0, import_promises.writeFile)(path, file.source);
53
54
  }
@@ -92,6 +93,14 @@ function fixturePath(root, path) {
92
93
  if (!resolved.startsWith(root + import_node_path.sep)) throw new Error(`unsafe example path: ${path}`);
93
94
  return resolved;
94
95
  }
96
+ function validateFixtureSyntax(exampleId, file, path) {
97
+ if (!/\.[cm]?[jt]sx?$/u.test(file.path)) return;
98
+ try {
99
+ parser.parse(file.source, { filePath: path });
100
+ } catch (error) {
101
+ throw new Error(`${exampleId}: invalid fixture ${file.path}`, { cause: error });
102
+ }
103
+ }
95
104
  async function verifyExample(linter, config, example, root, ruleId) {
96
105
  const focus = example.files.find((file) => file.path === example.focusPath);
97
106
  if (focus === void 0) throw new Error(`${example.id}: focus file is missing`);
@@ -100,6 +109,19 @@ async function verifyExample(linter, config, example, root, ruleId) {
100
109
  if (messages.some((message) => message.fatal || message.ruleId !== ruleId)) {
101
110
  throw new Error(`${example.id}: invalid fixture: ${messages.map((message) => message.message).join("; ")}`);
102
111
  }
112
+ const lines = linter.getSourceCode().lines;
113
+ if (messages.some((message) => !physicalDiagnosticLocation(message, lines))) {
114
+ throw new Error(`${example.id}: invalid diagnostic location`);
115
+ }
116
+ const locations = messages.map((message) => JSON.stringify([
117
+ message.ruleId,
118
+ message.messageId ?? message.message,
119
+ message.line,
120
+ message.column,
121
+ message.endLine,
122
+ message.endColumn
123
+ ]));
124
+ if (new Set(locations).size !== locations.length) throw new Error(`${example.id}: duplicate diagnostic`);
103
125
  if (messages.length !== example.expectedCount) {
104
126
  throw new Error(`${example.id}: expected ${example.expectedCount} findings, received ${messages.length}`);
105
127
  }
@@ -116,6 +138,17 @@ async function verifyExample(linter, config, example, root, ruleId) {
116
138
  }
117
139
  }
118
140
  }
141
+ function physicalDiagnosticLocation(message, lines) {
142
+ if (!physicalPosition(lines, message.line, message.column)) return false;
143
+ if (message.endLine === void 0 && message.endColumn === void 0) return true;
144
+ if (!physicalPosition(lines, message.endLine, message.endColumn)) return false;
145
+ return message.endLine !== void 0 && message.endColumn !== void 0 && (message.endLine > message.line || message.endLine === message.line && message.endColumn >= message.column);
146
+ }
147
+ function physicalPosition(lines, line, column) {
148
+ if (line === void 0 || column === void 0 || !Number.isInteger(line) || !Number.isInteger(column)) return false;
149
+ const text = lines[line - 1];
150
+ return text !== void 0 && column >= 1 && column <= text.length + 1;
151
+ }
119
152
  // Annotate the CommonJS export names for ESM import in node:
120
153
  0 && (module.exports = {
121
154
  verifyRuleExamples
@@ -14,6 +14,7 @@ async function verifyRuleExamples(rule, setup = {}) {
14
14
  try {
15
15
  for (const file of example.files) {
16
16
  const path = fixturePath(root, file.path);
17
+ validateFixtureSyntax(example.id, file, path);
17
18
  await mkdir(dirname(path), { recursive: true });
18
19
  await writeFile(path, file.source);
19
20
  }
@@ -58,6 +59,14 @@ function fixturePath(root, path) {
58
59
  if (!resolved.startsWith(root + sep)) throw new Error(`unsafe example path: ${path}`);
59
60
  return resolved;
60
61
  }
62
+ function validateFixtureSyntax(exampleId, file, path) {
63
+ if (!/\.[cm]?[jt]sx?$/u.test(file.path)) return;
64
+ try {
65
+ parser.parse(file.source, { filePath: path });
66
+ } catch (error) {
67
+ throw new Error(`${exampleId}: invalid fixture ${file.path}`, { cause: error });
68
+ }
69
+ }
61
70
  async function verifyExample(linter, config, example, root, ruleId) {
62
71
  const focus = example.files.find((file) => file.path === example.focusPath);
63
72
  if (focus === void 0) throw new Error(`${example.id}: focus file is missing`);
@@ -66,6 +75,19 @@ async function verifyExample(linter, config, example, root, ruleId) {
66
75
  if (messages.some((message) => message.fatal || message.ruleId !== ruleId)) {
67
76
  throw new Error(`${example.id}: invalid fixture: ${messages.map((message) => message.message).join("; ")}`);
68
77
  }
78
+ const lines = linter.getSourceCode().lines;
79
+ if (messages.some((message) => !physicalDiagnosticLocation(message, lines))) {
80
+ throw new Error(`${example.id}: invalid diagnostic location`);
81
+ }
82
+ const locations = messages.map((message) => JSON.stringify([
83
+ message.ruleId,
84
+ message.messageId ?? message.message,
85
+ message.line,
86
+ message.column,
87
+ message.endLine,
88
+ message.endColumn
89
+ ]));
90
+ if (new Set(locations).size !== locations.length) throw new Error(`${example.id}: duplicate diagnostic`);
69
91
  if (messages.length !== example.expectedCount) {
70
92
  throw new Error(`${example.id}: expected ${example.expectedCount} findings, received ${messages.length}`);
71
93
  }
@@ -82,6 +104,17 @@ async function verifyExample(linter, config, example, root, ruleId) {
82
104
  }
83
105
  }
84
106
  }
107
+ function physicalDiagnosticLocation(message, lines) {
108
+ if (!physicalPosition(lines, message.line, message.column)) return false;
109
+ if (message.endLine === void 0 && message.endColumn === void 0) return true;
110
+ if (!physicalPosition(lines, message.endLine, message.endColumn)) return false;
111
+ return message.endLine !== void 0 && message.endColumn !== void 0 && (message.endLine > message.line || message.endLine === message.line && message.endColumn >= message.column);
112
+ }
113
+ function physicalPosition(lines, line, column) {
114
+ if (line === void 0 || column === void 0 || !Number.isInteger(line) || !Number.isInteger(column)) return false;
115
+ const text = lines[line - 1];
116
+ return text !== void 0 && column >= 1 && column <= text.length + 1;
117
+ }
85
118
  export {
86
119
  verifyRuleExamples
87
120
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "16.15.0",
3
+ "version": "16.15.2",
4
4
  "packageManager": "npm@12.2.0",
5
5
  "description": "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
6
6
  "type": "module",