@sarj/eslint-plugin 16.16.0 → 16.16.1

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.16.0",
55
+ version: "16.16.1",
56
56
  packageManager: "npm@12.2.0",
57
57
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
58
58
  type: "module",
@@ -13647,26 +13647,30 @@ var no_string_concat_in_loop_default = createRule({
13647
13647
  var import_utils75 = require("@typescript-eslint/utils");
13648
13648
  var TEST_MODULES = /* @__PURE__ */ new Set(["vitest", "@jest/globals", "bun:test"]);
13649
13649
  var TEST_NAMES2 = /* @__PURE__ */ new Set(["it", "test"]);
13650
- var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "fails", "failing", "todo"]);
13650
+ var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "sequential", "fails", "failing", "todo"]);
13651
13651
  var NO_DUPLICATE_TEST_CASE_DOCUMENTATION = {
13652
13652
  defaultLevel: "error",
13653
- summary: "Report repeated literal rows in framework test.each or it.each tables.",
13653
+ summary: "Report repeated literal rows in framework each tables and Vitest for tables.",
13654
13654
  rationale: "Identical rows repeat the same inputs and expected values without exposing a distinct case. Accidental duplicates consume test time and hide a missing boundary vector.",
13655
13655
  remediation: "Remove the accidental duplicate or replace it with the intended distinct boundary. Preserve intentional repeated or stateful scenarios with a local explanation; do not delete their coverage automatically.",
13656
13656
  category: "testing",
13657
13657
  limitations: [
13658
- "Only literal rows in inline arrays or const tables used exclusively by each calls are compared. Calls, spreads, computed keys, getters, runtime values, tagged tables and mutable or escaped tables are excluded.",
13659
- "Object property order and all row fields, including names and IDs, remain part of case identity. Imported test aliases are resolved; custom runners and namespace imports are excluded.",
13658
+ "Only literal rows in inline arrays or const tables used exclusively by each or Vitest for calls are compared. Calls, spreads, computed keys, getters, runtime values, tagged tables and mutable or escaped tables are excluded.",
13659
+ "Object property order and all row fields, including names and IDs, remain part of case identity; equivalent quoted and unquoted property names share one identity. Runtime imported test aliases are resolved; type-only imports, custom runners and namespace imports are excluded.",
13660
13660
  "The rule reports duplicate values, not runtime equivalence. There is no autofix because repeated invocations may exercise shared state or concurrency."
13661
13661
  ],
13662
13662
  examples: [
13663
13663
  { id: "duplicate-row", title: "A copied row misses the intended boundary", outcome: "match", files: [{ path: "src/format.test.ts", source: "import {it} from 'vitest'; it.each([[0, '0ms'], [0, '0ms']])('%s', (input, expected) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 1, public: true },
13664
- { id: "distinct-boundary", title: "Distinct inputs have independent rows", outcome: "no-match", files: [{ path: "src/format.test.ts", source: "import {it} from 'vitest'; it.each([[0, '0ms'], [1, '1ms']])('%s', (input, expected) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 0, public: true }
13664
+ { id: "distinct-boundary", title: "Distinct inputs have independent rows", outcome: "no-match", files: [{ path: "src/format.test.ts", source: "import {it} from 'vitest'; it.each([[0, '0ms'], [1, '1ms']])('%s', (input, expected) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 0, public: true },
13665
+ { id: "duplicate-for-row", scenarioId: "for-table", title: "Vitest for tables retain the same case identity", outcome: "match", files: [{ path: "src/format.test.ts", source: "import {test} from 'vitest'; test.for([{input: 0, expected: '0ms'}, {'input': 0, expected: '0ms'}])('$input', ({input, expected}) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 1, public: true },
13666
+ { id: "distinct-for-boundary", scenarioId: "for-table", title: "Vitest for tables preserve distinct boundaries", outcome: "no-match", files: [{ path: "src/format.test.ts", source: "import {test} from 'vitest'; test.for([{input: 0, expected: '0ms'}, {'input': 1, expected: '1ms'}])('$input', ({input, expected}) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 0, public: true }
13665
13667
  ]
13666
13668
  };
13667
- function frameworkEach(node, sourceCode) {
13669
+ function frameworkTable(node, sourceCode) {
13668
13670
  let callee = unwrapExpression(node.callee);
13669
- if (callee.type !== import_utils75.AST_NODE_TYPES.MemberExpression || import_utils75.ASTUtils.getPropertyName(callee) !== "each") return false;
13671
+ if (callee.type !== import_utils75.AST_NODE_TYPES.MemberExpression) return false;
13672
+ const method = import_utils75.ASTUtils.getPropertyName(callee);
13673
+ if (method !== "each" && method !== "for") return false;
13670
13674
  callee = unwrapExpression(callee.object);
13671
13675
  while (callee.type === import_utils75.AST_NODE_TYPES.MemberExpression) {
13672
13676
  const name = import_utils75.ASTUtils.getPropertyName(callee);
@@ -13675,12 +13679,12 @@ function frameworkEach(node, sourceCode) {
13675
13679
  }
13676
13680
  if (callee.type !== import_utils75.AST_NODE_TYPES.Identifier) return false;
13677
13681
  const variable = import_utils75.ASTUtils.findVariable(sourceCode.getScope(callee), callee.name);
13678
- if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name);
13682
+ if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name) && !variable?.references.some((reference) => reference.isWrite());
13679
13683
  return variable.defs.length === 1 && variable.defs.every((definition) => {
13680
- if (definition.node.type !== import_utils75.AST_NODE_TYPES.ImportSpecifier) return false;
13684
+ if (definition.node.type !== import_utils75.AST_NODE_TYPES.ImportSpecifier || definition.node.importKind === "type") return false;
13681
13685
  const imported = definition.node.imported;
13682
13686
  const declaration = definition.node.parent;
13683
- return declaration.type === import_utils75.AST_NODE_TYPES.ImportDeclaration && TEST_NAMES2.has(imported.type === import_utils75.AST_NODE_TYPES.Identifier ? imported.name : imported.value) && TEST_MODULES.has(declaration.source.value);
13687
+ return declaration.type === import_utils75.AST_NODE_TYPES.ImportDeclaration && declaration.importKind !== "type" && TEST_NAMES2.has(imported.type === import_utils75.AST_NODE_TYPES.Identifier ? imported.name : imported.value) && (method === "for" ? declaration.source.value === "vitest" : TEST_MODULES.has(declaration.source.value));
13684
13688
  });
13685
13689
  }
13686
13690
  function table(node, sourceCode) {
@@ -13694,7 +13698,7 @@ function table(node, sourceCode) {
13694
13698
  if (reference.init) return true;
13695
13699
  if (reference.identifier.type !== import_utils75.AST_NODE_TYPES.Identifier) return false;
13696
13700
  const use = outerExpression(reference.identifier);
13697
- return use.parent.type === import_utils75.AST_NODE_TYPES.CallExpression && use.parent.arguments[0] === use && frameworkEach(use.parent, sourceCode);
13701
+ return use.parent.type === import_utils75.AST_NODE_TYPES.CallExpression && use.parent.arguments[0] === use && frameworkTable(use.parent, sourceCode);
13698
13702
  })) return null;
13699
13703
  const initializer = unwrapExpression(definition.node.init);
13700
13704
  return initializer.type === import_utils75.AST_NODE_TYPES.ArrayExpression ? initializer : null;
@@ -13724,7 +13728,7 @@ function objectKey(node) {
13724
13728
  const fields = [];
13725
13729
  for (const property of node.properties) {
13726
13730
  if (property.type !== import_utils75.AST_NODE_TYPES.Property || property.computed || property.kind !== "init" || property.method) return null;
13727
- const key = property.key.type === import_utils75.AST_NODE_TYPES.Identifier ? property.key.name : literalKey(property.key);
13731
+ const key = import_utils75.ASTUtils.getPropertyName(property);
13728
13732
  const value = literalKey(property.value);
13729
13733
  if (key === null || value === null) return null;
13730
13734
  fields.push(JSON.stringify([key, value]));
@@ -13747,7 +13751,7 @@ var no_duplicate_test_case_default = createRule({
13747
13751
  return {
13748
13752
  CallExpression(node) {
13749
13753
  const callee = unwrapExpression(node.callee);
13750
- if (callee.type !== import_utils75.AST_NODE_TYPES.CallExpression || !frameworkEach(callee, context.sourceCode)) return;
13754
+ if (callee.type !== import_utils75.AST_NODE_TYPES.CallExpression || !frameworkTable(callee, context.sourceCode)) return;
13751
13755
  const input = callee.arguments[0];
13752
13756
  if (callee.arguments.length !== 1 || input === void 0) return;
13753
13757
  const cases = table(input, context.sourceCode);
@@ -21559,10 +21563,12 @@ var PREFER_WHOLE_OBJECT_ASSERTION_DOCUMENTATION = {
21559
21563
  category: "testing",
21560
21564
  aliases: ["strict-test-assertions"],
21561
21565
  autofix: "none",
21562
- limitations: ["Undefined-property assertions are excluded because whole-object matching can require previously absent properties. No automatic rewrite: grouping can change observable getter or proxy reads."],
21566
+ limitations: ["Undefined-property assertions are excluded because whole-object matching can require previously absent properties. Indexed runs with an immediately adjacent literal exact-length assertion are excluded; non-adjacent length checks and intentional prefix contracts remain manual review. No automatic rewrite: grouping can change observable getter or proxy reads."],
21563
21567
  examples: [
21564
21568
  { id: "whole-object", title: "Assert the object once", outcome: "no-match", files: [{ path: "src/user.test.ts", source: "expect(user).toMatchObject({ id: 1, name: 'Ada' });" }], focusPath: "src/user.test.ts", expectedCount: 0, public: true },
21565
- { id: "member-run", title: "Consider grouping related data properties", outcome: "match", files: [{ path: "src/user.test.ts", source: "expect(user.id).toBe(1);\nexpect(user.name).toBe('Ada');" }], focusPath: "src/user.test.ts", expectedCount: 1, public: true }
21569
+ { id: "member-run", title: "Consider grouping related data properties", outcome: "match", files: [{ path: "src/user.test.ts", source: "expect(user.id).toBe(1);\nexpect(user.name).toBe('Ada');" }], focusPath: "src/user.test.ts", expectedCount: 1, public: true },
21570
+ { id: "indexed-prefix", scenarioId: "array-length", title: "Review the collection contract before combining indexed assertions", outcome: "match", files: [{ path: "src/user.test.ts", source: "expect(users[0]).toEqual('Ada');\nexpect(users[1]).toEqual('Grace');" }], focusPath: "src/user.test.ts", expectedCount: 1, public: true },
21571
+ { id: "length-and-prefix", scenarioId: "array-length", title: "A separate exact length can accompany an intentional prefix contract", outcome: "no-match", files: [{ path: "src/user.test.ts", source: "expect(users).toHaveLength(3);\nexpect(users[0]).toEqual('Ada');\nexpect(users[1]).toEqual('Grace');" }], focusPath: "src/user.test.ts", expectedCount: 0, public: true }
21566
21572
  ]
21567
21573
  };
21568
21574
  function literalText(node, getText) {
@@ -21623,7 +21629,7 @@ var prefer_whole_object_assertion_default = createRule({
21623
21629
  },
21624
21630
  messages: {
21625
21631
  combineAssertions: "Consider combining these {{count}} assertions on `{{receiver}}` with `toMatchObject` when structural matching preserves property presence and getter or proxy behavior.",
21626
- assertArrayOnce: "These {{count}} assertions check `{{receiver}}[0]`\u2026`{{receiver}}[{{last}}]` one at a time, which never checks how long `{{receiver}}` is \u2014 extra elements pass unnoticed. Assert the array once: `expect({{receiver}}).{{matcher}}([ \u2026 ])`."
21632
+ assertArrayOnce: "These {{count}} assertions check `{{receiver}}[0]`\u2026`{{receiver}}[{{last}}]` separately. If the contract requires the complete array, consider `expect({{receiver}}).{{matcher}}([ \u2026 ])`, which also checks for extra elements. Preserve intentional prefix checks and separate length assertions."
21627
21633
  },
21628
21634
  schema: []
21629
21635
  },
@@ -21689,7 +21695,7 @@ var prefer_whole_object_assertion_default = createRule({
21689
21695
  data: { count: String(run.length), receiver: receiverText }
21690
21696
  });
21691
21697
  }
21692
- function reportIndexRun(run) {
21698
+ function reportIndexRun(run, before, after) {
21693
21699
  const first = run[0];
21694
21700
  if (first === void 0 || !ARRAY_MATCHERS.has(first.matcher)) {
21695
21701
  return;
@@ -21704,6 +21710,7 @@ var prefer_whole_object_assertion_default = createRule({
21704
21710
  if (indices.size !== run.length || Math.max(...indices) !== run.length - 1) {
21705
21711
  return;
21706
21712
  }
21713
+ if (isExactLengthAssertion(before, first.receiver) || isExactLengthAssertion(after, first.receiver)) return;
21707
21714
  context.report({
21708
21715
  node: first.statement,
21709
21716
  messageId: "assertArrayOnce",
@@ -21717,18 +21724,19 @@ var prefer_whole_object_assertion_default = createRule({
21717
21724
  }
21718
21725
  function checkBody(body2) {
21719
21726
  let run = [];
21727
+ let start = 0;
21720
21728
  const flush = () => {
21721
21729
  const first = run[0];
21722
21730
  if (first !== void 0 && run.length >= MIN_RUN_LENGTH) {
21723
21731
  if (first.key.kind === "property") {
21724
21732
  reportPropertyRun(run);
21725
21733
  } else {
21726
- reportIndexRun(run);
21734
+ reportIndexRun(run, body2[start - 1], body2[start + run.length]);
21727
21735
  }
21728
21736
  }
21729
21737
  run = [];
21730
21738
  };
21731
- for (const statement of body2) {
21739
+ for (const [index, statement] of body2.entries()) {
21732
21740
  const assertion = parseAssertion(statement);
21733
21741
  const previous = run.at(-1);
21734
21742
  if (assertion !== null && previous !== void 0 && previous.key.kind === assertion.key.kind && sourceCode.getText(previous.receiver) === sourceCode.getText(assertion.receiver)) {
@@ -21738,6 +21746,7 @@ var prefer_whole_object_assertion_default = createRule({
21738
21746
  flush();
21739
21747
  if (assertion !== null) {
21740
21748
  run = [assertion];
21749
+ start = index;
21741
21750
  }
21742
21751
  }
21743
21752
  flush();
@@ -21783,14 +21792,26 @@ var prefer_whole_object_assertion_default = createRule({
21783
21792
  return assertionExpectation(statement, call, receiver, key, matcher);
21784
21793
  }
21785
21794
  function isTestExpect(callee) {
21786
- const variable = import_utils118.ASTUtils.findVariable(sourceCode.getScope(callee), callee.name);
21787
- if (variable !== null && variable.defs.some((definition) => {
21788
- if (definition.node.type !== import_utils118.AST_NODE_TYPES.ImportSpecifier) return true;
21789
- const declaration = definition.node.parent;
21790
- const imported = definition.node.imported;
21791
- return declaration.type !== import_utils118.AST_NODE_TYPES.ImportDeclaration || !["vitest", "@jest/globals", "@playwright/test", "bun:test"].includes(String(declaration.source.value)) || (imported.type === import_utils118.AST_NODE_TYPES.Identifier ? imported.name : imported.value) !== "expect";
21792
- })) return false;
21793
- return true;
21795
+ return runtimeTestFrameworkName(sourceCode, callee, "assertion") === "expect";
21796
+ }
21797
+ function isExactLengthAssertion(statement, receiver) {
21798
+ if (statement?.type !== import_utils118.AST_NODE_TYPES.ExpressionStatement || statement.expression.type !== import_utils118.AST_NODE_TYPES.CallExpression) return false;
21799
+ const call = statement.expression;
21800
+ const callee = unwrapExpression(call.callee);
21801
+ if (callee.type !== import_utils118.AST_NODE_TYPES.MemberExpression || call.arguments.length !== 1) return false;
21802
+ const matcher = import_utils118.ASTUtils.getPropertyName(callee);
21803
+ const assertion = unwrapExpression(callee.object);
21804
+ if (assertion.type !== import_utils118.AST_NODE_TYPES.CallExpression || assertion.arguments.length !== 1) return false;
21805
+ const expect = unwrapExpression(assertion.callee);
21806
+ if (expect.type !== import_utils118.AST_NODE_TYPES.Identifier || !isTestExpect(expect)) return false;
21807
+ const expected = call.arguments[0] === void 0 ? void 0 : unwrapExpression(call.arguments[0]);
21808
+ if (expected?.type !== import_utils118.AST_NODE_TYPES.Literal || typeof expected.value !== "number" || !Number.isInteger(expected.value) || expected.value < 0) return false;
21809
+ let actual = assertion.arguments[0] === void 0 ? void 0 : unwrapExpression(assertion.arguments[0]);
21810
+ if (matcher !== "toHaveLength") {
21811
+ if (matcher === null || !MERGEABLE_MATCHERS.has(matcher) || actual?.type !== import_utils118.AST_NODE_TYPES.MemberExpression || actual.optional || import_utils118.ASTUtils.getPropertyName(actual) !== "length") return false;
21812
+ actual = unwrapExpression(actual.object);
21813
+ }
21814
+ return actual !== void 0 && sourceCode.getText(actual) === sourceCode.getText(receiver);
21794
21815
  }
21795
21816
  function assertionExpectation(statement, call, receiver, key, matcher) {
21796
21817
  const synthetic = SYNTHETIC_LITERAL_MATCHERS.get(matcher);
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.16.0",
4
+ version: "16.16.1",
5
5
  packageManager: "npm@12.2.0",
6
6
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
7
7
  type: "module",
@@ -13628,26 +13628,30 @@ var no_string_concat_in_loop_default = createRule({
13628
13628
  import { AST_NODE_TYPES as AST_NODE_TYPES61, ASTUtils as ASTUtils37 } from "@typescript-eslint/utils";
13629
13629
  var TEST_MODULES = /* @__PURE__ */ new Set(["vitest", "@jest/globals", "bun:test"]);
13630
13630
  var TEST_NAMES2 = /* @__PURE__ */ new Set(["it", "test"]);
13631
- var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "fails", "failing", "todo"]);
13631
+ var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "sequential", "fails", "failing", "todo"]);
13632
13632
  var NO_DUPLICATE_TEST_CASE_DOCUMENTATION = {
13633
13633
  defaultLevel: "error",
13634
- summary: "Report repeated literal rows in framework test.each or it.each tables.",
13634
+ summary: "Report repeated literal rows in framework each tables and Vitest for tables.",
13635
13635
  rationale: "Identical rows repeat the same inputs and expected values without exposing a distinct case. Accidental duplicates consume test time and hide a missing boundary vector.",
13636
13636
  remediation: "Remove the accidental duplicate or replace it with the intended distinct boundary. Preserve intentional repeated or stateful scenarios with a local explanation; do not delete their coverage automatically.",
13637
13637
  category: "testing",
13638
13638
  limitations: [
13639
- "Only literal rows in inline arrays or const tables used exclusively by each calls are compared. Calls, spreads, computed keys, getters, runtime values, tagged tables and mutable or escaped tables are excluded.",
13640
- "Object property order and all row fields, including names and IDs, remain part of case identity. Imported test aliases are resolved; custom runners and namespace imports are excluded.",
13639
+ "Only literal rows in inline arrays or const tables used exclusively by each or Vitest for calls are compared. Calls, spreads, computed keys, getters, runtime values, tagged tables and mutable or escaped tables are excluded.",
13640
+ "Object property order and all row fields, including names and IDs, remain part of case identity; equivalent quoted and unquoted property names share one identity. Runtime imported test aliases are resolved; type-only imports, custom runners and namespace imports are excluded.",
13641
13641
  "The rule reports duplicate values, not runtime equivalence. There is no autofix because repeated invocations may exercise shared state or concurrency."
13642
13642
  ],
13643
13643
  examples: [
13644
13644
  { id: "duplicate-row", title: "A copied row misses the intended boundary", outcome: "match", files: [{ path: "src/format.test.ts", source: "import {it} from 'vitest'; it.each([[0, '0ms'], [0, '0ms']])('%s', (input, expected) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 1, public: true },
13645
- { id: "distinct-boundary", title: "Distinct inputs have independent rows", outcome: "no-match", files: [{ path: "src/format.test.ts", source: "import {it} from 'vitest'; it.each([[0, '0ms'], [1, '1ms']])('%s', (input, expected) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 0, public: true }
13645
+ { id: "distinct-boundary", title: "Distinct inputs have independent rows", outcome: "no-match", files: [{ path: "src/format.test.ts", source: "import {it} from 'vitest'; it.each([[0, '0ms'], [1, '1ms']])('%s', (input, expected) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 0, public: true },
13646
+ { id: "duplicate-for-row", scenarioId: "for-table", title: "Vitest for tables retain the same case identity", outcome: "match", files: [{ path: "src/format.test.ts", source: "import {test} from 'vitest'; test.for([{input: 0, expected: '0ms'}, {'input': 0, expected: '0ms'}])('$input', ({input, expected}) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 1, public: true },
13647
+ { id: "distinct-for-boundary", scenarioId: "for-table", title: "Vitest for tables preserve distinct boundaries", outcome: "no-match", files: [{ path: "src/format.test.ts", source: "import {test} from 'vitest'; test.for([{input: 0, expected: '0ms'}, {'input': 1, expected: '1ms'}])('$input', ({input, expected}) => { expect(format(input)).toBe(expected); });" }], focusPath: "src/format.test.ts", expectedCount: 0, public: true }
13646
13648
  ]
13647
13649
  };
13648
- function frameworkEach(node, sourceCode) {
13650
+ function frameworkTable(node, sourceCode) {
13649
13651
  let callee = unwrapExpression(node.callee);
13650
- if (callee.type !== AST_NODE_TYPES61.MemberExpression || ASTUtils37.getPropertyName(callee) !== "each") return false;
13652
+ if (callee.type !== AST_NODE_TYPES61.MemberExpression) return false;
13653
+ const method = ASTUtils37.getPropertyName(callee);
13654
+ if (method !== "each" && method !== "for") return false;
13651
13655
  callee = unwrapExpression(callee.object);
13652
13656
  while (callee.type === AST_NODE_TYPES61.MemberExpression) {
13653
13657
  const name = ASTUtils37.getPropertyName(callee);
@@ -13656,12 +13660,12 @@ function frameworkEach(node, sourceCode) {
13656
13660
  }
13657
13661
  if (callee.type !== AST_NODE_TYPES61.Identifier) return false;
13658
13662
  const variable = ASTUtils37.findVariable(sourceCode.getScope(callee), callee.name);
13659
- if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name);
13663
+ if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name) && !variable?.references.some((reference) => reference.isWrite());
13660
13664
  return variable.defs.length === 1 && variable.defs.every((definition) => {
13661
- if (definition.node.type !== AST_NODE_TYPES61.ImportSpecifier) return false;
13665
+ if (definition.node.type !== AST_NODE_TYPES61.ImportSpecifier || definition.node.importKind === "type") return false;
13662
13666
  const imported = definition.node.imported;
13663
13667
  const declaration = definition.node.parent;
13664
- return declaration.type === AST_NODE_TYPES61.ImportDeclaration && TEST_NAMES2.has(imported.type === AST_NODE_TYPES61.Identifier ? imported.name : imported.value) && TEST_MODULES.has(declaration.source.value);
13668
+ return declaration.type === AST_NODE_TYPES61.ImportDeclaration && declaration.importKind !== "type" && TEST_NAMES2.has(imported.type === AST_NODE_TYPES61.Identifier ? imported.name : imported.value) && (method === "for" ? declaration.source.value === "vitest" : TEST_MODULES.has(declaration.source.value));
13665
13669
  });
13666
13670
  }
13667
13671
  function table(node, sourceCode) {
@@ -13675,7 +13679,7 @@ function table(node, sourceCode) {
13675
13679
  if (reference.init) return true;
13676
13680
  if (reference.identifier.type !== AST_NODE_TYPES61.Identifier) return false;
13677
13681
  const use = outerExpression(reference.identifier);
13678
- return use.parent.type === AST_NODE_TYPES61.CallExpression && use.parent.arguments[0] === use && frameworkEach(use.parent, sourceCode);
13682
+ return use.parent.type === AST_NODE_TYPES61.CallExpression && use.parent.arguments[0] === use && frameworkTable(use.parent, sourceCode);
13679
13683
  })) return null;
13680
13684
  const initializer = unwrapExpression(definition.node.init);
13681
13685
  return initializer.type === AST_NODE_TYPES61.ArrayExpression ? initializer : null;
@@ -13705,7 +13709,7 @@ function objectKey(node) {
13705
13709
  const fields = [];
13706
13710
  for (const property of node.properties) {
13707
13711
  if (property.type !== AST_NODE_TYPES61.Property || property.computed || property.kind !== "init" || property.method) return null;
13708
- const key = property.key.type === AST_NODE_TYPES61.Identifier ? property.key.name : literalKey(property.key);
13712
+ const key = ASTUtils37.getPropertyName(property);
13709
13713
  const value = literalKey(property.value);
13710
13714
  if (key === null || value === null) return null;
13711
13715
  fields.push(JSON.stringify([key, value]));
@@ -13728,7 +13732,7 @@ var no_duplicate_test_case_default = createRule({
13728
13732
  return {
13729
13733
  CallExpression(node) {
13730
13734
  const callee = unwrapExpression(node.callee);
13731
- if (callee.type !== AST_NODE_TYPES61.CallExpression || !frameworkEach(callee, context.sourceCode)) return;
13735
+ if (callee.type !== AST_NODE_TYPES61.CallExpression || !frameworkTable(callee, context.sourceCode)) return;
13732
13736
  const input = callee.arguments[0];
13733
13737
  if (callee.arguments.length !== 1 || input === void 0) return;
13734
13738
  const cases = table(input, context.sourceCode);
@@ -21567,10 +21571,12 @@ var PREFER_WHOLE_OBJECT_ASSERTION_DOCUMENTATION = {
21567
21571
  category: "testing",
21568
21572
  aliases: ["strict-test-assertions"],
21569
21573
  autofix: "none",
21570
- limitations: ["Undefined-property assertions are excluded because whole-object matching can require previously absent properties. No automatic rewrite: grouping can change observable getter or proxy reads."],
21574
+ limitations: ["Undefined-property assertions are excluded because whole-object matching can require previously absent properties. Indexed runs with an immediately adjacent literal exact-length assertion are excluded; non-adjacent length checks and intentional prefix contracts remain manual review. No automatic rewrite: grouping can change observable getter or proxy reads."],
21571
21575
  examples: [
21572
21576
  { id: "whole-object", title: "Assert the object once", outcome: "no-match", files: [{ path: "src/user.test.ts", source: "expect(user).toMatchObject({ id: 1, name: 'Ada' });" }], focusPath: "src/user.test.ts", expectedCount: 0, public: true },
21573
- { id: "member-run", title: "Consider grouping related data properties", outcome: "match", files: [{ path: "src/user.test.ts", source: "expect(user.id).toBe(1);\nexpect(user.name).toBe('Ada');" }], focusPath: "src/user.test.ts", expectedCount: 1, public: true }
21577
+ { id: "member-run", title: "Consider grouping related data properties", outcome: "match", files: [{ path: "src/user.test.ts", source: "expect(user.id).toBe(1);\nexpect(user.name).toBe('Ada');" }], focusPath: "src/user.test.ts", expectedCount: 1, public: true },
21578
+ { id: "indexed-prefix", scenarioId: "array-length", title: "Review the collection contract before combining indexed assertions", outcome: "match", files: [{ path: "src/user.test.ts", source: "expect(users[0]).toEqual('Ada');\nexpect(users[1]).toEqual('Grace');" }], focusPath: "src/user.test.ts", expectedCount: 1, public: true },
21579
+ { id: "length-and-prefix", scenarioId: "array-length", title: "A separate exact length can accompany an intentional prefix contract", outcome: "no-match", files: [{ path: "src/user.test.ts", source: "expect(users).toHaveLength(3);\nexpect(users[0]).toEqual('Ada');\nexpect(users[1]).toEqual('Grace');" }], focusPath: "src/user.test.ts", expectedCount: 0, public: true }
21574
21580
  ]
21575
21581
  };
21576
21582
  function literalText(node, getText) {
@@ -21631,7 +21637,7 @@ var prefer_whole_object_assertion_default = createRule({
21631
21637
  },
21632
21638
  messages: {
21633
21639
  combineAssertions: "Consider combining these {{count}} assertions on `{{receiver}}` with `toMatchObject` when structural matching preserves property presence and getter or proxy behavior.",
21634
- assertArrayOnce: "These {{count}} assertions check `{{receiver}}[0]`\u2026`{{receiver}}[{{last}}]` one at a time, which never checks how long `{{receiver}}` is \u2014 extra elements pass unnoticed. Assert the array once: `expect({{receiver}}).{{matcher}}([ \u2026 ])`."
21640
+ assertArrayOnce: "These {{count}} assertions check `{{receiver}}[0]`\u2026`{{receiver}}[{{last}}]` separately. If the contract requires the complete array, consider `expect({{receiver}}).{{matcher}}([ \u2026 ])`, which also checks for extra elements. Preserve intentional prefix checks and separate length assertions."
21635
21641
  },
21636
21642
  schema: []
21637
21643
  },
@@ -21697,7 +21703,7 @@ var prefer_whole_object_assertion_default = createRule({
21697
21703
  data: { count: String(run.length), receiver: receiverText }
21698
21704
  });
21699
21705
  }
21700
- function reportIndexRun(run) {
21706
+ function reportIndexRun(run, before, after) {
21701
21707
  const first = run[0];
21702
21708
  if (first === void 0 || !ARRAY_MATCHERS.has(first.matcher)) {
21703
21709
  return;
@@ -21712,6 +21718,7 @@ var prefer_whole_object_assertion_default = createRule({
21712
21718
  if (indices.size !== run.length || Math.max(...indices) !== run.length - 1) {
21713
21719
  return;
21714
21720
  }
21721
+ if (isExactLengthAssertion(before, first.receiver) || isExactLengthAssertion(after, first.receiver)) return;
21715
21722
  context.report({
21716
21723
  node: first.statement,
21717
21724
  messageId: "assertArrayOnce",
@@ -21725,18 +21732,19 @@ var prefer_whole_object_assertion_default = createRule({
21725
21732
  }
21726
21733
  function checkBody(body2) {
21727
21734
  let run = [];
21735
+ let start = 0;
21728
21736
  const flush = () => {
21729
21737
  const first = run[0];
21730
21738
  if (first !== void 0 && run.length >= MIN_RUN_LENGTH) {
21731
21739
  if (first.key.kind === "property") {
21732
21740
  reportPropertyRun(run);
21733
21741
  } else {
21734
- reportIndexRun(run);
21742
+ reportIndexRun(run, body2[start - 1], body2[start + run.length]);
21735
21743
  }
21736
21744
  }
21737
21745
  run = [];
21738
21746
  };
21739
- for (const statement of body2) {
21747
+ for (const [index, statement] of body2.entries()) {
21740
21748
  const assertion = parseAssertion(statement);
21741
21749
  const previous = run.at(-1);
21742
21750
  if (assertion !== null && previous !== void 0 && previous.key.kind === assertion.key.kind && sourceCode.getText(previous.receiver) === sourceCode.getText(assertion.receiver)) {
@@ -21746,6 +21754,7 @@ var prefer_whole_object_assertion_default = createRule({
21746
21754
  flush();
21747
21755
  if (assertion !== null) {
21748
21756
  run = [assertion];
21757
+ start = index;
21749
21758
  }
21750
21759
  }
21751
21760
  flush();
@@ -21791,14 +21800,26 @@ var prefer_whole_object_assertion_default = createRule({
21791
21800
  return assertionExpectation(statement, call, receiver, key, matcher);
21792
21801
  }
21793
21802
  function isTestExpect(callee) {
21794
- const variable = ASTUtils63.findVariable(sourceCode.getScope(callee), callee.name);
21795
- if (variable !== null && variable.defs.some((definition) => {
21796
- if (definition.node.type !== AST_NODE_TYPES101.ImportSpecifier) return true;
21797
- const declaration = definition.node.parent;
21798
- const imported = definition.node.imported;
21799
- return declaration.type !== AST_NODE_TYPES101.ImportDeclaration || !["vitest", "@jest/globals", "@playwright/test", "bun:test"].includes(String(declaration.source.value)) || (imported.type === AST_NODE_TYPES101.Identifier ? imported.name : imported.value) !== "expect";
21800
- })) return false;
21801
- return true;
21803
+ return runtimeTestFrameworkName(sourceCode, callee, "assertion") === "expect";
21804
+ }
21805
+ function isExactLengthAssertion(statement, receiver) {
21806
+ if (statement?.type !== AST_NODE_TYPES101.ExpressionStatement || statement.expression.type !== AST_NODE_TYPES101.CallExpression) return false;
21807
+ const call = statement.expression;
21808
+ const callee = unwrapExpression(call.callee);
21809
+ if (callee.type !== AST_NODE_TYPES101.MemberExpression || call.arguments.length !== 1) return false;
21810
+ const matcher = ASTUtils63.getPropertyName(callee);
21811
+ const assertion = unwrapExpression(callee.object);
21812
+ if (assertion.type !== AST_NODE_TYPES101.CallExpression || assertion.arguments.length !== 1) return false;
21813
+ const expect = unwrapExpression(assertion.callee);
21814
+ if (expect.type !== AST_NODE_TYPES101.Identifier || !isTestExpect(expect)) return false;
21815
+ const expected = call.arguments[0] === void 0 ? void 0 : unwrapExpression(call.arguments[0]);
21816
+ if (expected?.type !== AST_NODE_TYPES101.Literal || typeof expected.value !== "number" || !Number.isInteger(expected.value) || expected.value < 0) return false;
21817
+ let actual = assertion.arguments[0] === void 0 ? void 0 : unwrapExpression(assertion.arguments[0]);
21818
+ if (matcher !== "toHaveLength") {
21819
+ if (matcher === null || !MERGEABLE_MATCHERS.has(matcher) || actual?.type !== AST_NODE_TYPES101.MemberExpression || actual.optional || ASTUtils63.getPropertyName(actual) !== "length") return false;
21820
+ actual = unwrapExpression(actual.object);
21821
+ }
21822
+ return actual !== void 0 && sourceCode.getText(actual) === sourceCode.getText(receiver);
21802
21823
  }
21803
21824
  function assertionExpectation(statement, call, receiver, key, matcher) {
21804
21825
  const synthetic = SYNTHETIC_LITERAL_MATCHERS.get(matcher);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "16.16.0",
3
+ "version": "16.16.1",
4
4
  "packageManager": "npm@12.2.0",
5
5
  "description": "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
6
6
  "type": "module",