@sarj/eslint-plugin 16.19.0 → 16.19.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 +169 -13
  2. package/dist/index.js +169 -13
  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.19.0",
55
+ version: "16.19.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",
@@ -14349,14 +14349,16 @@ var no_duplicate_test_case_default = createRule({
14349
14349
  // src/rules/no-tautological-expect.ts
14350
14350
  var import_utils80 = require("@typescript-eslint/utils");
14351
14351
  var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
14352
- summary: "Disallow supported assertions known to pass from literals or immutable scalar setup.",
14353
- rationale: "An assertion determined entirely by literals does not observe the code under test and can keep passing after that code is removed.",
14352
+ summary: "Disallow supported assertions fixed by literal setup or immutable self identity.",
14353
+ rationale: "An assertion fixed by literal setup or one immutable binding's identity cannot distinguish a wrong result from the intended behavior.",
14354
14354
  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.",
14355
14355
  category: "testing",
14356
14356
  limitations: [
14357
- "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.",
14358
- "Scalar const bindings and aliases resolve through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded.",
14359
- "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."
14357
+ "Only supported `expect` matcher calls in recognized test files are inspected, including imported aliases, static string messages on imported Vitest/Playwright expect calls, 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.",
14358
+ "The literal proof resolves scalar const bindings and aliases through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded from that proof.",
14359
+ "Positive toBe assertions on the same initialized, unreassigned local identifier const binding also have fixed identity, including runtime results. Different bindings, imported live values, property/getter accesses and deep-equality reflexivity remain excluded from this identity proof.",
14360
+ "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.",
14361
+ "Direct framework expect.extend registrations exclude replaced built-in matchers; opaque registrations exclude all supported matchers, and nonempty custom equality testers exclude deep equality. Direct synchronous expect callbacks observed by a throwing matcher retain their assertion contracts. Cross-file or indirect matcher customization is not inferred."
14360
14362
  ],
14361
14363
  examples: [
14362
14364
  {
@@ -14416,6 +14418,86 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
14416
14418
  focusPath: "src/service.test.ts",
14417
14419
  expectedCount: 0,
14418
14420
  public: true
14421
+ },
14422
+ {
14423
+ id: "message-only-oracle",
14424
+ scenarioId: "static-message",
14425
+ title: "A diagnostic string does not make a literal comparison observe behavior",
14426
+ outcome: "match",
14427
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; expect(true, 'service ready').toBe(true);" }],
14428
+ focusPath: "src/service.test.ts",
14429
+ expectedCount: 1,
14430
+ public: true
14431
+ },
14432
+ {
14433
+ id: "message-produced-oracle",
14434
+ scenarioId: "static-message",
14435
+ title: "A diagnostic message can describe an observed result",
14436
+ outcome: "no-match",
14437
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; expect(serviceReady(), 'service ready').toBe(true);" }],
14438
+ focusPath: "src/service.test.ts",
14439
+ expectedCount: 0,
14440
+ public: true
14441
+ },
14442
+ {
14443
+ id: "uncaptured-matcher",
14444
+ scenarioId: "matcher-contract",
14445
+ title: "An uncaptured literal assertion remains redundant",
14446
+ outcome: "match",
14447
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect(true).toBeTruthy();" }],
14448
+ focusPath: "src/assertion.test.ts",
14449
+ expectedCount: 1,
14450
+ public: true
14451
+ },
14452
+ {
14453
+ id: "captured-matcher",
14454
+ scenarioId: "matcher-contract",
14455
+ title: "A throwing matcher observes the inner assertion contract",
14456
+ outcome: "no-match",
14457
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect(() => expect(true).toBeTruthy()).not.toThrow();" }],
14458
+ focusPath: "src/assertion.test.ts",
14459
+ expectedCount: 0,
14460
+ public: true
14461
+ },
14462
+ {
14463
+ id: "unrelated-custom-matcher",
14464
+ scenarioId: "custom-matcher",
14465
+ title: "Adding a different matcher preserves the built-in proof",
14466
+ outcome: "match",
14467
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect.extend({toBeReady: custom}); expect(true).toBe(true);" }],
14468
+ focusPath: "src/assertion.test.ts",
14469
+ expectedCount: 1,
14470
+ public: true
14471
+ },
14472
+ {
14473
+ id: "replaced-custom-matcher",
14474
+ scenarioId: "custom-matcher",
14475
+ title: "A replaced matcher can reject a literal comparison",
14476
+ outcome: "no-match",
14477
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect.extend({toBe: () => ({pass: false, message: () => 'custom contract'})}); expect(true).toBe(true);" }],
14478
+ focusPath: "src/assertion.test.ts",
14479
+ expectedCount: 0,
14480
+ public: true
14481
+ },
14482
+ {
14483
+ id: "self-identity-oracle",
14484
+ scenarioId: "immutable-identity",
14485
+ title: "A returned value's identity with itself cannot reject a wrong result",
14486
+ outcome: "match",
14487
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; const result = serviceStatus(); expect(result).toBe(result);" }],
14488
+ focusPath: "src/service.test.ts",
14489
+ expectedCount: 1,
14490
+ public: true
14491
+ },
14492
+ {
14493
+ id: "independent-result-oracle",
14494
+ scenarioId: "immutable-identity",
14495
+ title: "An independent expected state can reject a wrong result",
14496
+ outcome: "no-match",
14497
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; const result = serviceStatus(); expect(result.status).toBe('ready');" }],
14498
+ focusPath: "src/service.test.ts",
14499
+ expectedCount: 0,
14500
+ public: true
14419
14501
  }
14420
14502
  ]
14421
14503
  };
@@ -14430,6 +14512,7 @@ var ZERO_ARG_MATCHERS = /* @__PURE__ */ new Set([
14430
14512
  ]);
14431
14513
  var OPERAND_PREVIEW_CHARS = 40;
14432
14514
  var NUMERIC_SIGNS = /* @__PURE__ */ new Set(["-", "+"]);
14515
+ var THROW_MATCHERS = /* @__PURE__ */ new Set(["toThrow", "toThrowError", "toThrowErrorMatchingSnapshot", "toThrowErrorMatchingInlineSnapshot"]);
14433
14516
  function resolveLiteral(node, sourceCode, seen = /* @__PURE__ */ new Set()) {
14434
14517
  node = unwrapExpression(node);
14435
14518
  if (isLiteral(node)) return node;
@@ -14505,12 +14588,76 @@ function expectOperand(callee, sourceCode) {
14505
14588
  const negated = receiver.type === import_utils80.AST_NODE_TYPES.MemberExpression && import_utils80.ASTUtils.getPropertyName(receiver) === "not";
14506
14589
  if (negated && receiver.type === import_utils80.AST_NODE_TYPES.MemberExpression) receiver = unwrapExpression(receiver.object);
14507
14590
  const expectCallee = receiver.type === import_utils80.AST_NODE_TYPES.CallExpression ? unwrapExpression(receiver.callee) : null;
14508
- if (receiver.type !== import_utils80.AST_NODE_TYPES.CallExpression || expectCallee?.type !== import_utils80.AST_NODE_TYPES.Identifier || runtimeTestFrameworkName(sourceCode, expectCallee, "assertion") !== "expect" || receiver.arguments.length !== 1) {
14591
+ if (receiver.type !== import_utils80.AST_NODE_TYPES.CallExpression || expectCallee?.type !== import_utils80.AST_NODE_TYPES.Identifier || runtimeTestFrameworkName(sourceCode, expectCallee, "assertion") !== "expect" || receiver.arguments.length !== 1 && !hasStaticMessage(receiver, expectCallee, sourceCode)) {
14509
14592
  return null;
14510
14593
  }
14511
14594
  const operand = receiver.arguments[0];
14512
14595
  return operand === void 0 ? null : { operand, negated };
14513
14596
  }
14597
+ function hasStaticMessage(call, callee, source) {
14598
+ if (call.arguments.length !== 2) return false;
14599
+ const definition = import_utils80.ASTUtils.findVariable(source.getScope(callee), callee.name)?.defs[0];
14600
+ if (definition?.type !== "ImportBinding" || definition.parent.type !== import_utils80.AST_NODE_TYPES.ImportDeclaration || !["vitest", "@playwright/test"].includes(definition.parent.source.value)) return false;
14601
+ const message = call.arguments[1];
14602
+ const literal2 = message === void 0 ? null : resolveLiteral(message, source);
14603
+ return literal2?.type === import_utils80.AST_NODE_TYPES.Literal && typeof literal2.value === "string" || literal2?.type === import_utils80.AST_NODE_TYPES.TemplateLiteral;
14604
+ }
14605
+ function isExceptionContract(node, source) {
14606
+ let callback = node.parent;
14607
+ while (callback !== void 0 && callback.type !== import_utils80.AST_NODE_TYPES.Program && callback.type !== import_utils80.AST_NODE_TYPES.ArrowFunctionExpression && callback.type !== import_utils80.AST_NODE_TYPES.FunctionExpression && callback.type !== import_utils80.AST_NODE_TYPES.FunctionDeclaration) callback = callback.parent;
14608
+ if (callback === void 0 || callback.type === import_utils80.AST_NODE_TYPES.Program || callback.type === import_utils80.AST_NODE_TYPES.FunctionDeclaration || callback.async || callback.generator) return false;
14609
+ const call = directArgumentCall(callback);
14610
+ if (call === null || call.optional) return false;
14611
+ let member = outerExpression(call).parent;
14612
+ if (member?.type === import_utils80.AST_NODE_TYPES.MemberExpression && import_utils80.ASTUtils.getPropertyName(member) === "not") member = outerExpression(member).parent;
14613
+ if (member?.type !== import_utils80.AST_NODE_TYPES.MemberExpression || member.optional || !THROW_MATCHERS.has(import_utils80.ASTUtils.getPropertyName(member) ?? "")) return false;
14614
+ const invocation = outerExpression(member).parent;
14615
+ return invocation?.type === import_utils80.AST_NODE_TYPES.CallExpression && !invocation.optional && unwrapExpression(expectOperand(member, source)?.operand ?? member) === callback;
14616
+ }
14617
+ function customizedMatchers(source) {
14618
+ const customized = /* @__PURE__ */ new Set();
14619
+ const visit = (node) => {
14620
+ if (node.type === import_utils80.AST_NODE_TYPES.CallExpression) collectCustomization(node, source, customized);
14621
+ for (const key of source.visitorKeys[node.type] ?? []) {
14622
+ const child = node[key];
14623
+ for (const value of Array.isArray(child) ? child : [child]) {
14624
+ if (typeof value === "object" && value !== null && "type" in value) visit(value);
14625
+ }
14626
+ }
14627
+ };
14628
+ visit(source.ast);
14629
+ return customized;
14630
+ }
14631
+ function collectCustomization(node, source, customized) {
14632
+ const member = unwrapExpression(node.callee);
14633
+ if (member.type !== import_utils80.AST_NODE_TYPES.MemberExpression) return;
14634
+ const receiver = unwrapExpression(member.object);
14635
+ if (receiver.type !== import_utils80.AST_NODE_TYPES.Identifier || runtimeTestFrameworkName(source, receiver, "assertion") !== "expect") return;
14636
+ const method = import_utils80.ASTUtils.getPropertyName(member);
14637
+ const argument = node.arguments[0] === void 0 ? null : unwrapExpression(node.arguments[0]);
14638
+ if (method === "addEqualityTesters" && !(argument?.type === import_utils80.AST_NODE_TYPES.ArrayExpression && argument.elements.length === 0)) {
14639
+ customized.add("toEqual");
14640
+ customized.add("toStrictEqual");
14641
+ }
14642
+ if (method !== "extend") return;
14643
+ if (argument?.type !== import_utils80.AST_NODE_TYPES.ObjectExpression) {
14644
+ customized.add("*");
14645
+ return;
14646
+ }
14647
+ for (const property of argument.properties) {
14648
+ const name = property.type === import_utils80.AST_NODE_TYPES.Property ? import_utils80.ASTUtils.getPropertyName(property) : null;
14649
+ customized.add(name ?? "*");
14650
+ }
14651
+ }
14652
+ function hasConstSelfIdentity(actual, expected, source) {
14653
+ actual = unwrapExpression(actual);
14654
+ expected = unwrapExpression(expected);
14655
+ if (actual.type !== import_utils80.AST_NODE_TYPES.Identifier || expected.type !== import_utils80.AST_NODE_TYPES.Identifier) return false;
14656
+ const variable = import_utils80.ASTUtils.findVariable(source.getScope(actual), actual.name);
14657
+ if (variable?.defs.length !== 1 || import_utils80.ASTUtils.findVariable(source.getScope(expected), expected.name) !== variable) return false;
14658
+ const definition = variable.defs[0];
14659
+ return definition?.node.type === import_utils80.AST_NODE_TYPES.VariableDeclarator && definition.node.id.type === import_utils80.AST_NODE_TYPES.Identifier && definition.node.parent.kind === "const" && definition.node.init !== null && definition.node.init.range[1] <= Math.min(actual.range[0], expected.range[0]) && !variable.references.some((reference) => reference.isWrite() && !reference.init);
14660
+ }
14514
14661
  var no_tautological_expect_default = createRule({
14515
14662
  name: "no-tautological-expect",
14516
14663
  documentation: NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION,
@@ -14521,7 +14668,7 @@ var no_tautological_expect_default = createRule({
14521
14668
  },
14522
14669
  schema: [],
14523
14670
  messages: {
14524
- tautologicalComparison: "`expect({{operand}}).{{matcher}}({{operand}})` is fixed by literal setup and does not observe behavior. Assert on a produced value or remove only the redundant assertion, preserving other coverage.",
14671
+ tautologicalComparison: "`expect({{operand}}).{{matcher}}({{operand}})` is fixed by {{reason}} and does not observe behavior. Assert against an independent expectation or remove only the redundant assertion, preserving the action and other coverage.",
14525
14672
  tautologicalMatcher: "`expect({{operand}}).{{matcher}}()` is statically known to pass. Assert on a produced value or remove only the redundant assertion, preserving other coverage."
14526
14673
  }
14527
14674
  },
@@ -14530,6 +14677,7 @@ var no_tautological_expect_default = createRule({
14530
14677
  if (!isTestFile(context.filename)) {
14531
14678
  return {};
14532
14679
  }
14680
+ const customized = customizedMatchers(context.sourceCode);
14533
14681
  const preview2 = (node) => {
14534
14682
  const text = context.sourceCode.getText(node).replaceAll(/\s+/gu, " ");
14535
14683
  return text.length > OPERAND_PREVIEW_CHARS ? `${text.slice(0, OPERAND_PREVIEW_CHARS)}\u2026` : text;
@@ -14539,10 +14687,19 @@ var no_tautological_expect_default = createRule({
14539
14687
  const callee = unwrapExpression(node.callee);
14540
14688
  if (callee.type !== import_utils80.AST_NODE_TYPES.MemberExpression) return;
14541
14689
  const matcher = import_utils80.ASTUtils.getPropertyName(callee);
14542
- if (matcher === null) return;
14690
+ if (matcher === null || !ZERO_ARG_MATCHERS.has(matcher) && !EQUALITY_MATCHERS.has(matcher) || customized.has("*") || customized.has(matcher)) return;
14543
14691
  const assertion = expectOperand(callee, context.sourceCode);
14544
- if (assertion === null) return;
14692
+ if (assertion === null || isExceptionContract(node, context.sourceCode)) return;
14545
14693
  const writtenOperand = assertion.operand;
14694
+ const writtenExpected = node.arguments[0];
14695
+ if (!assertion.negated && matcher === "toBe" && node.arguments.length === 1 && writtenExpected !== void 0 && hasConstSelfIdentity(writtenOperand, writtenExpected, context.sourceCode)) {
14696
+ context.report({
14697
+ node,
14698
+ messageId: "tautologicalComparison",
14699
+ data: { operand: preview2(writtenOperand), matcher, reason: "the same immutable binding" }
14700
+ });
14701
+ return;
14702
+ }
14546
14703
  const operand = resolveLiteral(writtenOperand, context.sourceCode);
14547
14704
  if (operand === null) {
14548
14705
  return;
@@ -14556,7 +14713,6 @@ var no_tautological_expect_default = createRule({
14556
14713
  });
14557
14714
  return;
14558
14715
  }
14559
- const writtenExpected = node.arguments[0];
14560
14716
  const expected = writtenExpected === void 0 ? null : resolveLiteral(writtenExpected, context.sourceCode);
14561
14717
  if (assertion.negated || !EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === null) {
14562
14718
  return;
@@ -14570,7 +14726,7 @@ var no_tautological_expect_default = createRule({
14570
14726
  context.report({
14571
14727
  node,
14572
14728
  messageId: "tautologicalComparison",
14573
- data: { operand: preview2(writtenOperand), matcher }
14729
+ data: { operand: preview2(writtenOperand), matcher, reason: "literal setup" }
14574
14730
  });
14575
14731
  }
14576
14732
  };
@@ -26014,7 +26170,7 @@ function classMethodCounts(body2, methods) {
26014
26170
 
26015
26171
  // src/rules/source-coupled-test.ts
26016
26172
  var import_utils136 = require("@typescript-eslint/utils");
26017
- var GENERAL_SOURCE_SUFFIX_RE = /\.(?:bash|sh|ya?ml|jsonc?|toml|py|[cm]?[jt]s)$/iu;
26173
+ var GENERAL_SOURCE_SUFFIX_RE = /\.(?:bash|sh|ya?ml|jsonc|toml|py|[cm]?[jt]s)$|(?<!\.(?:tf|tftest))\.json$/iu;
26018
26174
  var FS_MODULES = /* @__PURE__ */ new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
26019
26175
  var FS_READERS = /* @__PURE__ */ new Set(["readFile", "readFileSync"]);
26020
26176
  var TEXT_TRANSFORMS = /* @__PURE__ */ new Set([
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.19.0",
4
+ version: "16.19.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",
@@ -14330,14 +14330,16 @@ var no_duplicate_test_case_default = createRule({
14330
14330
  // src/rules/no-tautological-expect.ts
14331
14331
  import { AST_NODE_TYPES as AST_NODE_TYPES65, ASTUtils as ASTUtils40 } from "@typescript-eslint/utils";
14332
14332
  var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
14333
- summary: "Disallow supported assertions known to pass from literals or immutable scalar setup.",
14334
- rationale: "An assertion determined entirely by literals does not observe the code under test and can keep passing after that code is removed.",
14333
+ summary: "Disallow supported assertions fixed by literal setup or immutable self identity.",
14334
+ rationale: "An assertion fixed by literal setup or one immutable binding's identity cannot distinguish a wrong result from the intended behavior.",
14335
14335
  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.",
14336
14336
  category: "testing",
14337
14337
  limitations: [
14338
- "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.",
14339
- "Scalar const bindings and aliases resolve through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded.",
14340
- "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."
14338
+ "Only supported `expect` matcher calls in recognized test files are inspected, including imported aliases, static string messages on imported Vitest/Playwright expect calls, 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.",
14339
+ "The literal proof resolves scalar const bindings and aliases through lexical scope; mutable bindings, objects, arrays, destructuring, runtime initializers and forward references are excluded from that proof.",
14340
+ "Positive toBe assertions on the same initialized, unreassigned local identifier const binding also have fixed identity, including runtime results. Different bindings, imported live values, property/getter accesses and deep-equality reflexivity remain excluded from this identity proof.",
14341
+ "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.",
14342
+ "Direct framework expect.extend registrations exclude replaced built-in matchers; opaque registrations exclude all supported matchers, and nonempty custom equality testers exclude deep equality. Direct synchronous expect callbacks observed by a throwing matcher retain their assertion contracts. Cross-file or indirect matcher customization is not inferred."
14341
14343
  ],
14342
14344
  examples: [
14343
14345
  {
@@ -14397,6 +14399,86 @@ var NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION = {
14397
14399
  focusPath: "src/service.test.ts",
14398
14400
  expectedCount: 0,
14399
14401
  public: true
14402
+ },
14403
+ {
14404
+ id: "message-only-oracle",
14405
+ scenarioId: "static-message",
14406
+ title: "A diagnostic string does not make a literal comparison observe behavior",
14407
+ outcome: "match",
14408
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; expect(true, 'service ready').toBe(true);" }],
14409
+ focusPath: "src/service.test.ts",
14410
+ expectedCount: 1,
14411
+ public: true
14412
+ },
14413
+ {
14414
+ id: "message-produced-oracle",
14415
+ scenarioId: "static-message",
14416
+ title: "A diagnostic message can describe an observed result",
14417
+ outcome: "no-match",
14418
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; expect(serviceReady(), 'service ready').toBe(true);" }],
14419
+ focusPath: "src/service.test.ts",
14420
+ expectedCount: 0,
14421
+ public: true
14422
+ },
14423
+ {
14424
+ id: "uncaptured-matcher",
14425
+ scenarioId: "matcher-contract",
14426
+ title: "An uncaptured literal assertion remains redundant",
14427
+ outcome: "match",
14428
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect(true).toBeTruthy();" }],
14429
+ focusPath: "src/assertion.test.ts",
14430
+ expectedCount: 1,
14431
+ public: true
14432
+ },
14433
+ {
14434
+ id: "captured-matcher",
14435
+ scenarioId: "matcher-contract",
14436
+ title: "A throwing matcher observes the inner assertion contract",
14437
+ outcome: "no-match",
14438
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect(() => expect(true).toBeTruthy()).not.toThrow();" }],
14439
+ focusPath: "src/assertion.test.ts",
14440
+ expectedCount: 0,
14441
+ public: true
14442
+ },
14443
+ {
14444
+ id: "unrelated-custom-matcher",
14445
+ scenarioId: "custom-matcher",
14446
+ title: "Adding a different matcher preserves the built-in proof",
14447
+ outcome: "match",
14448
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect.extend({toBeReady: custom}); expect(true).toBe(true);" }],
14449
+ focusPath: "src/assertion.test.ts",
14450
+ expectedCount: 1,
14451
+ public: true
14452
+ },
14453
+ {
14454
+ id: "replaced-custom-matcher",
14455
+ scenarioId: "custom-matcher",
14456
+ title: "A replaced matcher can reject a literal comparison",
14457
+ outcome: "no-match",
14458
+ files: [{ path: "src/assertion.test.ts", source: "import {expect} from 'vitest'; expect.extend({toBe: () => ({pass: false, message: () => 'custom contract'})}); expect(true).toBe(true);" }],
14459
+ focusPath: "src/assertion.test.ts",
14460
+ expectedCount: 0,
14461
+ public: true
14462
+ },
14463
+ {
14464
+ id: "self-identity-oracle",
14465
+ scenarioId: "immutable-identity",
14466
+ title: "A returned value's identity with itself cannot reject a wrong result",
14467
+ outcome: "match",
14468
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; const result = serviceStatus(); expect(result).toBe(result);" }],
14469
+ focusPath: "src/service.test.ts",
14470
+ expectedCount: 1,
14471
+ public: true
14472
+ },
14473
+ {
14474
+ id: "independent-result-oracle",
14475
+ scenarioId: "immutable-identity",
14476
+ title: "An independent expected state can reject a wrong result",
14477
+ outcome: "no-match",
14478
+ files: [{ path: "src/service.test.ts", source: "import {expect} from 'vitest'; const result = serviceStatus(); expect(result.status).toBe('ready');" }],
14479
+ focusPath: "src/service.test.ts",
14480
+ expectedCount: 0,
14481
+ public: true
14400
14482
  }
14401
14483
  ]
14402
14484
  };
@@ -14411,6 +14493,7 @@ var ZERO_ARG_MATCHERS = /* @__PURE__ */ new Set([
14411
14493
  ]);
14412
14494
  var OPERAND_PREVIEW_CHARS = 40;
14413
14495
  var NUMERIC_SIGNS = /* @__PURE__ */ new Set(["-", "+"]);
14496
+ var THROW_MATCHERS = /* @__PURE__ */ new Set(["toThrow", "toThrowError", "toThrowErrorMatchingSnapshot", "toThrowErrorMatchingInlineSnapshot"]);
14414
14497
  function resolveLiteral(node, sourceCode, seen = /* @__PURE__ */ new Set()) {
14415
14498
  node = unwrapExpression(node);
14416
14499
  if (isLiteral(node)) return node;
@@ -14486,12 +14569,76 @@ function expectOperand(callee, sourceCode) {
14486
14569
  const negated = receiver.type === AST_NODE_TYPES65.MemberExpression && ASTUtils40.getPropertyName(receiver) === "not";
14487
14570
  if (negated && receiver.type === AST_NODE_TYPES65.MemberExpression) receiver = unwrapExpression(receiver.object);
14488
14571
  const expectCallee = receiver.type === AST_NODE_TYPES65.CallExpression ? unwrapExpression(receiver.callee) : null;
14489
- if (receiver.type !== AST_NODE_TYPES65.CallExpression || expectCallee?.type !== AST_NODE_TYPES65.Identifier || runtimeTestFrameworkName(sourceCode, expectCallee, "assertion") !== "expect" || receiver.arguments.length !== 1) {
14572
+ if (receiver.type !== AST_NODE_TYPES65.CallExpression || expectCallee?.type !== AST_NODE_TYPES65.Identifier || runtimeTestFrameworkName(sourceCode, expectCallee, "assertion") !== "expect" || receiver.arguments.length !== 1 && !hasStaticMessage(receiver, expectCallee, sourceCode)) {
14490
14573
  return null;
14491
14574
  }
14492
14575
  const operand = receiver.arguments[0];
14493
14576
  return operand === void 0 ? null : { operand, negated };
14494
14577
  }
14578
+ function hasStaticMessage(call, callee, source) {
14579
+ if (call.arguments.length !== 2) return false;
14580
+ const definition = ASTUtils40.findVariable(source.getScope(callee), callee.name)?.defs[0];
14581
+ if (definition?.type !== "ImportBinding" || definition.parent.type !== AST_NODE_TYPES65.ImportDeclaration || !["vitest", "@playwright/test"].includes(definition.parent.source.value)) return false;
14582
+ const message = call.arguments[1];
14583
+ const literal2 = message === void 0 ? null : resolveLiteral(message, source);
14584
+ return literal2?.type === AST_NODE_TYPES65.Literal && typeof literal2.value === "string" || literal2?.type === AST_NODE_TYPES65.TemplateLiteral;
14585
+ }
14586
+ function isExceptionContract(node, source) {
14587
+ let callback = node.parent;
14588
+ while (callback !== void 0 && callback.type !== AST_NODE_TYPES65.Program && callback.type !== AST_NODE_TYPES65.ArrowFunctionExpression && callback.type !== AST_NODE_TYPES65.FunctionExpression && callback.type !== AST_NODE_TYPES65.FunctionDeclaration) callback = callback.parent;
14589
+ if (callback === void 0 || callback.type === AST_NODE_TYPES65.Program || callback.type === AST_NODE_TYPES65.FunctionDeclaration || callback.async || callback.generator) return false;
14590
+ const call = directArgumentCall(callback);
14591
+ if (call === null || call.optional) return false;
14592
+ let member = outerExpression(call).parent;
14593
+ if (member?.type === AST_NODE_TYPES65.MemberExpression && ASTUtils40.getPropertyName(member) === "not") member = outerExpression(member).parent;
14594
+ if (member?.type !== AST_NODE_TYPES65.MemberExpression || member.optional || !THROW_MATCHERS.has(ASTUtils40.getPropertyName(member) ?? "")) return false;
14595
+ const invocation = outerExpression(member).parent;
14596
+ return invocation?.type === AST_NODE_TYPES65.CallExpression && !invocation.optional && unwrapExpression(expectOperand(member, source)?.operand ?? member) === callback;
14597
+ }
14598
+ function customizedMatchers(source) {
14599
+ const customized = /* @__PURE__ */ new Set();
14600
+ const visit = (node) => {
14601
+ if (node.type === AST_NODE_TYPES65.CallExpression) collectCustomization(node, source, customized);
14602
+ for (const key of source.visitorKeys[node.type] ?? []) {
14603
+ const child = node[key];
14604
+ for (const value of Array.isArray(child) ? child : [child]) {
14605
+ if (typeof value === "object" && value !== null && "type" in value) visit(value);
14606
+ }
14607
+ }
14608
+ };
14609
+ visit(source.ast);
14610
+ return customized;
14611
+ }
14612
+ function collectCustomization(node, source, customized) {
14613
+ const member = unwrapExpression(node.callee);
14614
+ if (member.type !== AST_NODE_TYPES65.MemberExpression) return;
14615
+ const receiver = unwrapExpression(member.object);
14616
+ if (receiver.type !== AST_NODE_TYPES65.Identifier || runtimeTestFrameworkName(source, receiver, "assertion") !== "expect") return;
14617
+ const method = ASTUtils40.getPropertyName(member);
14618
+ const argument = node.arguments[0] === void 0 ? null : unwrapExpression(node.arguments[0]);
14619
+ if (method === "addEqualityTesters" && !(argument?.type === AST_NODE_TYPES65.ArrayExpression && argument.elements.length === 0)) {
14620
+ customized.add("toEqual");
14621
+ customized.add("toStrictEqual");
14622
+ }
14623
+ if (method !== "extend") return;
14624
+ if (argument?.type !== AST_NODE_TYPES65.ObjectExpression) {
14625
+ customized.add("*");
14626
+ return;
14627
+ }
14628
+ for (const property of argument.properties) {
14629
+ const name = property.type === AST_NODE_TYPES65.Property ? ASTUtils40.getPropertyName(property) : null;
14630
+ customized.add(name ?? "*");
14631
+ }
14632
+ }
14633
+ function hasConstSelfIdentity(actual, expected, source) {
14634
+ actual = unwrapExpression(actual);
14635
+ expected = unwrapExpression(expected);
14636
+ if (actual.type !== AST_NODE_TYPES65.Identifier || expected.type !== AST_NODE_TYPES65.Identifier) return false;
14637
+ const variable = ASTUtils40.findVariable(source.getScope(actual), actual.name);
14638
+ if (variable?.defs.length !== 1 || ASTUtils40.findVariable(source.getScope(expected), expected.name) !== variable) return false;
14639
+ const definition = variable.defs[0];
14640
+ return definition?.node.type === AST_NODE_TYPES65.VariableDeclarator && definition.node.id.type === AST_NODE_TYPES65.Identifier && definition.node.parent.kind === "const" && definition.node.init !== null && definition.node.init.range[1] <= Math.min(actual.range[0], expected.range[0]) && !variable.references.some((reference) => reference.isWrite() && !reference.init);
14641
+ }
14495
14642
  var no_tautological_expect_default = createRule({
14496
14643
  name: "no-tautological-expect",
14497
14644
  documentation: NO_TAUTOLOGICAL_EXPECT_DOCUMENTATION,
@@ -14502,7 +14649,7 @@ var no_tautological_expect_default = createRule({
14502
14649
  },
14503
14650
  schema: [],
14504
14651
  messages: {
14505
- tautologicalComparison: "`expect({{operand}}).{{matcher}}({{operand}})` is fixed by literal setup and does not observe behavior. Assert on a produced value or remove only the redundant assertion, preserving other coverage.",
14652
+ tautologicalComparison: "`expect({{operand}}).{{matcher}}({{operand}})` is fixed by {{reason}} and does not observe behavior. Assert against an independent expectation or remove only the redundant assertion, preserving the action and other coverage.",
14506
14653
  tautologicalMatcher: "`expect({{operand}}).{{matcher}}()` is statically known to pass. Assert on a produced value or remove only the redundant assertion, preserving other coverage."
14507
14654
  }
14508
14655
  },
@@ -14511,6 +14658,7 @@ var no_tautological_expect_default = createRule({
14511
14658
  if (!isTestFile(context.filename)) {
14512
14659
  return {};
14513
14660
  }
14661
+ const customized = customizedMatchers(context.sourceCode);
14514
14662
  const preview2 = (node) => {
14515
14663
  const text = context.sourceCode.getText(node).replaceAll(/\s+/gu, " ");
14516
14664
  return text.length > OPERAND_PREVIEW_CHARS ? `${text.slice(0, OPERAND_PREVIEW_CHARS)}\u2026` : text;
@@ -14520,10 +14668,19 @@ var no_tautological_expect_default = createRule({
14520
14668
  const callee = unwrapExpression(node.callee);
14521
14669
  if (callee.type !== AST_NODE_TYPES65.MemberExpression) return;
14522
14670
  const matcher = ASTUtils40.getPropertyName(callee);
14523
- if (matcher === null) return;
14671
+ if (matcher === null || !ZERO_ARG_MATCHERS.has(matcher) && !EQUALITY_MATCHERS.has(matcher) || customized.has("*") || customized.has(matcher)) return;
14524
14672
  const assertion = expectOperand(callee, context.sourceCode);
14525
- if (assertion === null) return;
14673
+ if (assertion === null || isExceptionContract(node, context.sourceCode)) return;
14526
14674
  const writtenOperand = assertion.operand;
14675
+ const writtenExpected = node.arguments[0];
14676
+ if (!assertion.negated && matcher === "toBe" && node.arguments.length === 1 && writtenExpected !== void 0 && hasConstSelfIdentity(writtenOperand, writtenExpected, context.sourceCode)) {
14677
+ context.report({
14678
+ node,
14679
+ messageId: "tautologicalComparison",
14680
+ data: { operand: preview2(writtenOperand), matcher, reason: "the same immutable binding" }
14681
+ });
14682
+ return;
14683
+ }
14527
14684
  const operand = resolveLiteral(writtenOperand, context.sourceCode);
14528
14685
  if (operand === null) {
14529
14686
  return;
@@ -14537,7 +14694,6 @@ var no_tautological_expect_default = createRule({
14537
14694
  });
14538
14695
  return;
14539
14696
  }
14540
- const writtenExpected = node.arguments[0];
14541
14697
  const expected = writtenExpected === void 0 ? null : resolveLiteral(writtenExpected, context.sourceCode);
14542
14698
  if (assertion.negated || !EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === null) {
14543
14699
  return;
@@ -14551,7 +14707,7 @@ var no_tautological_expect_default = createRule({
14551
14707
  context.report({
14552
14708
  node,
14553
14709
  messageId: "tautologicalComparison",
14554
- data: { operand: preview2(writtenOperand), matcher }
14710
+ data: { operand: preview2(writtenOperand), matcher, reason: "literal setup" }
14555
14711
  });
14556
14712
  }
14557
14713
  };
@@ -26035,7 +26191,7 @@ function classMethodCounts(body2, methods) {
26035
26191
 
26036
26192
  // src/rules/source-coupled-test.ts
26037
26193
  import { AST_NODE_TYPES as AST_NODE_TYPES116, ASTUtils as ASTUtils75 } from "@typescript-eslint/utils";
26038
- var GENERAL_SOURCE_SUFFIX_RE = /\.(?:bash|sh|ya?ml|jsonc?|toml|py|[cm]?[jt]s)$/iu;
26194
+ var GENERAL_SOURCE_SUFFIX_RE = /\.(?:bash|sh|ya?ml|jsonc|toml|py|[cm]?[jt]s)$|(?<!\.(?:tf|tftest))\.json$/iu;
26039
26195
  var FS_MODULES = /* @__PURE__ */ new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
26040
26196
  var FS_READERS = /* @__PURE__ */ new Set(["readFile", "readFileSync"]);
26041
26197
  var TEXT_TRANSFORMS = /* @__PURE__ */ new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "16.19.0",
3
+ "version": "16.19.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",