@sarj/eslint-plugin 16.15.1 → 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.
Files changed (3) hide show
  1. package/dist/index.cjs +111 -29
  2. package/dist/index.js +111 -29
  3. package/package.json +1 -1
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.1",
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",
@@ -9071,14 +9071,19 @@ var NO_BARE_RETURN_FROM_TEST_CATCH_DOCUMENTATION = {
9071
9071
  remediation: "Rethrow the error, assert on it, or use the runner's explicit skip mechanism when the capability is optional.",
9072
9072
  category: "testing",
9073
9073
  filePatterns: ["**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**"],
9074
- 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."],
9075
9075
  examples: [
9076
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 },
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 }
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 }
9078
9082
  ]
9079
9083
  };
9080
9084
  var TEST_NAMES = /* @__PURE__ */ new Set(["it", "test"]);
9081
9085
  var TEST_MODIFIERS2 = /* @__PURE__ */ new Set(["concurrent", "fails", "only", "sequential", "skip"]);
9086
+ var TEST_BUILDERS = /* @__PURE__ */ new Set(["each", "for", "runIf", "skipIf"]);
9082
9087
  var ASSERTION_NAMES = /* @__PURE__ */ new Set(["assert", "assertType", "expect", "expectTypeOf"]);
9083
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]);
9084
9089
  function staticMemberName3(node) {
@@ -9102,6 +9107,12 @@ function isDirectTestCallback(node, context) {
9102
9107
  function testRoot(callee) {
9103
9108
  callee = unwrapExpression(callee);
9104
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
+ }
9105
9116
  if (callee.type !== import_utils51.AST_NODE_TYPES.MemberExpression) return null;
9106
9117
  const modifier = staticMemberName3(callee);
9107
9118
  return modifier !== null && TEST_MODIFIERS2.has(modifier) ? testRoot(callee.object) : null;
@@ -9125,11 +9136,67 @@ function isExplicitSkip(node, context) {
9125
9136
  const root = rootIdentifier2(unwrappedNodeCallee.object);
9126
9137
  return root !== null && TEST_NAMES.has(runtimeTestFrameworkName(context.sourceCode, root, "test") ?? "");
9127
9138
  }
9128
- 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) {
9129
9196
  const block = node.parent;
9130
9197
  if (block?.type !== import_utils51.AST_NODE_TYPES.BlockStatement) return false;
9131
9198
  return block.body.some(
9132
- (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))
9133
9200
  );
9134
9201
  }
9135
9202
  var no_bare_return_from_test_catch_default = createRule({
@@ -9163,7 +9230,7 @@ var no_bare_return_from_test_catch_default = createRule({
9163
9230
  if (parameter?.type === import_utils51.AST_NODE_TYPES.Identifier && returnBlock?.type === import_utils51.AST_NODE_TYPES.BlockStatement) {
9164
9231
  const errorBinding = import_utils51.ASTUtils.findVariable(context.sourceCode.getScope(parameter), parameter.name);
9165
9232
  const assertedError = returnBlock.body.some((statement) => {
9166
- 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;
9167
9234
  const expression = statement.expression;
9168
9235
  if (expression.type !== import_utils51.AST_NODE_TYPES.CallExpression || !isAssertion(expression, context)) return false;
9169
9236
  const root = rootIdentifier2(expression.callee);
@@ -9176,7 +9243,7 @@ var no_bare_return_from_test_catch_default = createRule({
9176
9243
  });
9177
9244
  if (assertedError) return;
9178
9245
  }
9179
- if (hasDominatingExplicitSkip(node, context)) return;
9246
+ if (hasDominatingExplicitSkip(node, owner, context)) return;
9180
9247
  if (!walkOwnScope(owner.body, (current) => current.range[0] > node.range[1] && isAssertion(current, context))) return;
9181
9248
  context.report({ node, messageId: "bareReturnFromTestCatch" });
9182
9249
  }
@@ -13309,7 +13376,7 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13309
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.",
13310
13377
  category: "testing",
13311
13378
  limitations: [
13312
- "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.",
13313
13380
  "Scalar const bindings and aliases resolve through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded.",
13314
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."
13315
13382
  ],
@@ -13342,6 +13409,26 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13342
13409
  expectedCount: 1,
13343
13410
  public: true
13344
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
+ },
13345
13432
  {
13346
13433
  id: "produced-scalar-oracle",
13347
13434
  scenarioId: "setup-only",
@@ -13435,13 +13522,16 @@ function passesZeroArgumentMatcher(node, matcher) {
13435
13522
  return false;
13436
13523
  }
13437
13524
  }
13438
- function expectOperand(callee) {
13439
- 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);
13440
13529
  const expectCallee = receiver.type === import_utils74.AST_NODE_TYPES.CallExpression ? unwrapExpression(receiver.callee) : null;
13441
- 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) {
13442
13531
  return null;
13443
13532
  }
13444
- return receiver.arguments[0] ?? null;
13533
+ const operand = receiver.arguments[0];
13534
+ return operand === void 0 ? null : { operand, negated };
13445
13535
  }
13446
13536
  var no_tautological_expect_default = createRule({
13447
13537
  name: "no-tautological-expect",
@@ -13472,33 +13562,25 @@ var no_tautological_expect_default = createRule({
13472
13562
  if (callee.type !== import_utils74.AST_NODE_TYPES.MemberExpression) return;
13473
13563
  const matcher = import_utils74.ASTUtils.getPropertyName(callee);
13474
13564
  if (matcher === null) return;
13475
- const receiver = unwrapExpression(callee.object);
13476
- if (receiver.type !== import_utils74.AST_NODE_TYPES.CallExpression) return;
13477
- const expectIdentifier = unwrapExpression(receiver.callee);
13478
- if (expectIdentifier.type !== import_utils74.AST_NODE_TYPES.Identifier) return;
13479
- const variable = import_utils74.ASTUtils.findVariable(context.sourceCode.getScope(expectIdentifier), expectIdentifier.name);
13480
- if (variable !== null && variable.defs.some((definition) => {
13481
- if (definition.node.type !== import_utils74.AST_NODE_TYPES.ImportSpecifier) return true;
13482
- const declaration = definition.node.parent;
13483
- const imported = definition.node.imported;
13484
- 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";
13485
- })) return;
13486
- const writtenOperand = expectOperand(callee);
13487
- 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);
13488
13569
  if (operand === null) {
13489
13570
  return;
13490
13571
  }
13491
- 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) {
13492
13574
  context.report({
13493
13575
  node,
13494
13576
  messageId: "tautologicalMatcher",
13495
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13577
+ data: { operand: preview2(writtenOperand), matcher: writtenMatcher }
13496
13578
  });
13497
13579
  return;
13498
13580
  }
13499
13581
  const writtenExpected = node.arguments[0];
13500
13582
  const expected = writtenExpected === void 0 ? null : resolveLiteral(writtenExpected, context.sourceCode);
13501
- 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) {
13502
13584
  return;
13503
13585
  }
13504
13586
  if (matcher === "toBe" && (isStructuralLiteral(operand) || isStructuralLiteral(expected))) {
@@ -13510,7 +13592,7 @@ var no_tautological_expect_default = createRule({
13510
13592
  context.report({
13511
13593
  node,
13512
13594
  messageId: "tautologicalComparison",
13513
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13595
+ data: { operand: preview2(writtenOperand), matcher }
13514
13596
  });
13515
13597
  }
13516
13598
  };
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.1",
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",
@@ -9052,14 +9052,19 @@ var NO_BARE_RETURN_FROM_TEST_CATCH_DOCUMENTATION = {
9052
9052
  remediation: "Rethrow the error, assert on it, or use the runner's explicit skip mechanism when the capability is optional.",
9053
9053
  category: "testing",
9054
9054
  filePatterns: ["**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**"],
9055
- 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."],
9056
9056
  examples: [
9057
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 },
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 }
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 }
9059
9063
  ]
9060
9064
  };
9061
9065
  var TEST_NAMES = /* @__PURE__ */ new Set(["it", "test"]);
9062
9066
  var TEST_MODIFIERS2 = /* @__PURE__ */ new Set(["concurrent", "fails", "only", "sequential", "skip"]);
9067
+ var TEST_BUILDERS = /* @__PURE__ */ new Set(["each", "for", "runIf", "skipIf"]);
9063
9068
  var ASSERTION_NAMES = /* @__PURE__ */ new Set(["assert", "assertType", "expect", "expectTypeOf"]);
9064
9069
  var FUNCTION_TYPES3 = /* @__PURE__ */ new Set([AST_NODE_TYPES43.ArrowFunctionExpression, AST_NODE_TYPES43.FunctionExpression, AST_NODE_TYPES43.FunctionDeclaration]);
9065
9070
  function staticMemberName3(node) {
@@ -9083,6 +9088,12 @@ function isDirectTestCallback(node, context) {
9083
9088
  function testRoot(callee) {
9084
9089
  callee = unwrapExpression(callee);
9085
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
+ }
9086
9097
  if (callee.type !== AST_NODE_TYPES43.MemberExpression) return null;
9087
9098
  const modifier = staticMemberName3(callee);
9088
9099
  return modifier !== null && TEST_MODIFIERS2.has(modifier) ? testRoot(callee.object) : null;
@@ -9106,11 +9117,67 @@ function isExplicitSkip(node, context) {
9106
9117
  const root = rootIdentifier2(unwrappedNodeCallee.object);
9107
9118
  return root !== null && TEST_NAMES.has(runtimeTestFrameworkName(context.sourceCode, root, "test") ?? "");
9108
9119
  }
9109
- 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) {
9110
9177
  const block = node.parent;
9111
9178
  if (block?.type !== AST_NODE_TYPES43.BlockStatement) return false;
9112
9179
  return block.body.some(
9113
- (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))
9114
9181
  );
9115
9182
  }
9116
9183
  var no_bare_return_from_test_catch_default = createRule({
@@ -9144,7 +9211,7 @@ var no_bare_return_from_test_catch_default = createRule({
9144
9211
  if (parameter?.type === AST_NODE_TYPES43.Identifier && returnBlock?.type === AST_NODE_TYPES43.BlockStatement) {
9145
9212
  const errorBinding = ASTUtils22.findVariable(context.sourceCode.getScope(parameter), parameter.name);
9146
9213
  const assertedError = returnBlock.body.some((statement) => {
9147
- 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;
9148
9215
  const expression = statement.expression;
9149
9216
  if (expression.type !== AST_NODE_TYPES43.CallExpression || !isAssertion(expression, context)) return false;
9150
9217
  const root = rootIdentifier2(expression.callee);
@@ -9157,7 +9224,7 @@ var no_bare_return_from_test_catch_default = createRule({
9157
9224
  });
9158
9225
  if (assertedError) return;
9159
9226
  }
9160
- if (hasDominatingExplicitSkip(node, context)) return;
9227
+ if (hasDominatingExplicitSkip(node, owner, context)) return;
9161
9228
  if (!walkOwnScope(owner.body, (current) => current.range[0] > node.range[1] && isAssertion(current, context))) return;
9162
9229
  context.report({ node, messageId: "bareReturnFromTestCatch" });
9163
9230
  }
@@ -13290,7 +13357,7 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13290
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.",
13291
13358
  category: "testing",
13292
13359
  limitations: [
13293
- "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.",
13294
13361
  "Scalar const bindings and aliases resolve through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded.",
13295
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."
13296
13363
  ],
@@ -13323,6 +13390,26 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
13323
13390
  expectedCount: 1,
13324
13391
  public: true
13325
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
+ },
13326
13413
  {
13327
13414
  id: "produced-scalar-oracle",
13328
13415
  scenarioId: "setup-only",
@@ -13416,13 +13503,16 @@ function passesZeroArgumentMatcher(node, matcher) {
13416
13503
  return false;
13417
13504
  }
13418
13505
  }
13419
- function expectOperand(callee) {
13420
- 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);
13421
13510
  const expectCallee = receiver.type === AST_NODE_TYPES60.CallExpression ? unwrapExpression(receiver.callee) : null;
13422
- 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) {
13423
13512
  return null;
13424
13513
  }
13425
- return receiver.arguments[0] ?? null;
13514
+ const operand = receiver.arguments[0];
13515
+ return operand === void 0 ? null : { operand, negated };
13426
13516
  }
13427
13517
  var no_tautological_expect_default = createRule({
13428
13518
  name: "no-tautological-expect",
@@ -13453,33 +13543,25 @@ var no_tautological_expect_default = createRule({
13453
13543
  if (callee.type !== AST_NODE_TYPES60.MemberExpression) return;
13454
13544
  const matcher = ASTUtils36.getPropertyName(callee);
13455
13545
  if (matcher === null) return;
13456
- const receiver = unwrapExpression(callee.object);
13457
- if (receiver.type !== AST_NODE_TYPES60.CallExpression) return;
13458
- const expectIdentifier = unwrapExpression(receiver.callee);
13459
- if (expectIdentifier.type !== AST_NODE_TYPES60.Identifier) return;
13460
- const variable = ASTUtils36.findVariable(context.sourceCode.getScope(expectIdentifier), expectIdentifier.name);
13461
- if (variable !== null && variable.defs.some((definition) => {
13462
- if (definition.node.type !== AST_NODE_TYPES60.ImportSpecifier) return true;
13463
- const declaration = definition.node.parent;
13464
- const imported = definition.node.imported;
13465
- 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";
13466
- })) return;
13467
- const writtenOperand = expectOperand(callee);
13468
- 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);
13469
13550
  if (operand === null) {
13470
13551
  return;
13471
13552
  }
13472
- 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) {
13473
13555
  context.report({
13474
13556
  node,
13475
13557
  messageId: "tautologicalMatcher",
13476
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13558
+ data: { operand: preview2(writtenOperand), matcher: writtenMatcher }
13477
13559
  });
13478
13560
  return;
13479
13561
  }
13480
13562
  const writtenExpected = node.arguments[0];
13481
13563
  const expected = writtenExpected === void 0 ? null : resolveLiteral(writtenExpected, context.sourceCode);
13482
- 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) {
13483
13565
  return;
13484
13566
  }
13485
13567
  if (matcher === "toBe" && (isStructuralLiteral(operand) || isStructuralLiteral(expected))) {
@@ -13491,7 +13573,7 @@ var no_tautological_expect_default = createRule({
13491
13573
  context.report({
13492
13574
  node,
13493
13575
  messageId: "tautologicalComparison",
13494
- data: { operand: preview2(writtenOperand ?? operand), matcher }
13576
+ data: { operand: preview2(writtenOperand), matcher }
13495
13577
  });
13496
13578
  }
13497
13579
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "16.15.1",
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",