@sarj/eslint-plugin 16.16.0 → 16.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -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.17.0",
56
56
  packageManager: "npm@12.2.0",
57
57
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
58
58
  type: "module",
@@ -87,12 +87,12 @@ var package_default = {
87
87
  scripts: {
88
88
  build: "tsup",
89
89
  dogfood: "npm run build && npm run dogfood:built",
90
- lint: "eslint . ../../.github/scripts ../standards/src ../standards/eslint.config.mjs --max-warnings 0",
90
+ lint: "eslint . ../../.github/scripts ../standards/src ../standards/eslint.config.mts --max-warnings 0",
91
91
  prepack: "npm run build && npm run verify-package",
92
92
  test: "vitest run",
93
- typecheck: "tsc --noEmit",
93
+ typecheck: "tsc --noEmit && tsc --project tsconfig.tools.json",
94
94
  "verify-package": `node -e "const fs=require('node:fs'),p=require('./package.json'),walk=x=>typeof x==='string'?[x]:x&&typeof x==='object'?Object.values(x).flatMap(walk):[];for(const file of new Set([p.main,p.module,p.types,...walk(p.exports)].filter(Boolean).map(x=>x.startsWith('./')?x.slice(2):x))){if(!file.startsWith('dist/'))throw new Error('export must live under dist: '+file);const stat=fs.statSync(file);if(!stat.isFile()||stat.size===0)throw new Error('missing or empty export: '+file)}"`,
95
- "dogfood:built": `eslint --config eslint.dogfood.config.mjs src tests eslint.config.mjs eslint.dogfood.config.mjs module-constant-naming-options.mjs tsup.config.ts vitest.config.ts --max-warnings 0 && node -e "import('./dist/index.js').then(({default:p}) => console.log('dogfood: '+Object.keys(p.rules).length+' TypeScript rules, 0 diagnostics'))"`
95
+ "dogfood:built": `eslint --config eslint.dogfood.config.ts src tests scripts eslint-rule-setting.ts eslint.config.ts eslint.dogfood.config.ts module-constant-naming-options.ts tsup.config.ts vitest.config.ts --max-warnings 0 && node -e "import('./dist/index.js').then(({default:p}) => console.log('dogfood: '+Object.keys(p.rules).length+' TypeScript rules, 0 diagnostics'))"`
96
96
  },
97
97
  keywords: [
98
98
  "eslint",
@@ -142,7 +142,8 @@ var package_default = {
142
142
  typescript: "npm:@typescript/typescript6@6.0.2",
143
143
  "typescript-eslint": "^8.71.2",
144
144
  vitest: "5.0.4",
145
- zod: "4.6.7"
145
+ zod: "4.6.7",
146
+ jiti: "2.7.0"
146
147
  },
147
148
  peerDependencies: {
148
149
  eslint: ">=9.39.4",
@@ -13647,26 +13648,30 @@ var no_string_concat_in_loop_default = createRule({
13647
13648
  var import_utils75 = require("@typescript-eslint/utils");
13648
13649
  var TEST_MODULES = /* @__PURE__ */ new Set(["vitest", "@jest/globals", "bun:test"]);
13649
13650
  var TEST_NAMES2 = /* @__PURE__ */ new Set(["it", "test"]);
13650
- var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "fails", "failing", "todo"]);
13651
+ var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "sequential", "fails", "failing", "todo"]);
13651
13652
  var NO_DUPLICATE_TEST_CASE_DOCUMENTATION = {
13652
13653
  defaultLevel: "error",
13653
- summary: "Report repeated literal rows in framework test.each or it.each tables.",
13654
+ summary: "Report repeated literal rows in framework each tables and Vitest for tables.",
13654
13655
  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
13656
  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
13657
  category: "testing",
13657
13658
  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.",
13659
+ "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.",
13660
+ "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
13661
  "The rule reports duplicate values, not runtime equivalence. There is no autofix because repeated invocations may exercise shared state or concurrency."
13661
13662
  ],
13662
13663
  examples: [
13663
13664
  { 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 }
13665
+ { 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 },
13666
+ { 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 },
13667
+ { 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
13668
  ]
13666
13669
  };
13667
- function frameworkEach(node, sourceCode) {
13670
+ function frameworkTable(node, sourceCode) {
13668
13671
  let callee = unwrapExpression(node.callee);
13669
- if (callee.type !== import_utils75.AST_NODE_TYPES.MemberExpression || import_utils75.ASTUtils.getPropertyName(callee) !== "each") return false;
13672
+ if (callee.type !== import_utils75.AST_NODE_TYPES.MemberExpression) return false;
13673
+ const method = import_utils75.ASTUtils.getPropertyName(callee);
13674
+ if (method !== "each" && method !== "for") return false;
13670
13675
  callee = unwrapExpression(callee.object);
13671
13676
  while (callee.type === import_utils75.AST_NODE_TYPES.MemberExpression) {
13672
13677
  const name = import_utils75.ASTUtils.getPropertyName(callee);
@@ -13675,12 +13680,12 @@ function frameworkEach(node, sourceCode) {
13675
13680
  }
13676
13681
  if (callee.type !== import_utils75.AST_NODE_TYPES.Identifier) return false;
13677
13682
  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);
13683
+ if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name) && !variable?.references.some((reference) => reference.isWrite());
13679
13684
  return variable.defs.length === 1 && variable.defs.every((definition) => {
13680
- if (definition.node.type !== import_utils75.AST_NODE_TYPES.ImportSpecifier) return false;
13685
+ if (definition.node.type !== import_utils75.AST_NODE_TYPES.ImportSpecifier || definition.node.importKind === "type") return false;
13681
13686
  const imported = definition.node.imported;
13682
13687
  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);
13688
+ 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
13689
  });
13685
13690
  }
13686
13691
  function table(node, sourceCode) {
@@ -13694,7 +13699,7 @@ function table(node, sourceCode) {
13694
13699
  if (reference.init) return true;
13695
13700
  if (reference.identifier.type !== import_utils75.AST_NODE_TYPES.Identifier) return false;
13696
13701
  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);
13702
+ return use.parent.type === import_utils75.AST_NODE_TYPES.CallExpression && use.parent.arguments[0] === use && frameworkTable(use.parent, sourceCode);
13698
13703
  })) return null;
13699
13704
  const initializer = unwrapExpression(definition.node.init);
13700
13705
  return initializer.type === import_utils75.AST_NODE_TYPES.ArrayExpression ? initializer : null;
@@ -13724,7 +13729,7 @@ function objectKey(node) {
13724
13729
  const fields = [];
13725
13730
  for (const property of node.properties) {
13726
13731
  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);
13732
+ const key = import_utils75.ASTUtils.getPropertyName(property);
13728
13733
  const value = literalKey(property.value);
13729
13734
  if (key === null || value === null) return null;
13730
13735
  fields.push(JSON.stringify([key, value]));
@@ -13747,7 +13752,7 @@ var no_duplicate_test_case_default = createRule({
13747
13752
  return {
13748
13753
  CallExpression(node) {
13749
13754
  const callee = unwrapExpression(node.callee);
13750
- if (callee.type !== import_utils75.AST_NODE_TYPES.CallExpression || !frameworkEach(callee, context.sourceCode)) return;
13755
+ if (callee.type !== import_utils75.AST_NODE_TYPES.CallExpression || !frameworkTable(callee, context.sourceCode)) return;
13751
13756
  const input = callee.arguments[0];
13752
13757
  if (callee.arguments.length !== 1 || input === void 0) return;
13753
13758
  const cases = table(input, context.sourceCode);
@@ -21559,10 +21564,12 @@ var PREFER_WHOLE_OBJECT_ASSERTION_DOCUMENTATION = {
21559
21564
  category: "testing",
21560
21565
  aliases: ["strict-test-assertions"],
21561
21566
  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."],
21567
+ 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
21568
  examples: [
21564
21569
  { 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 }
21570
+ { 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 },
21571
+ { 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 },
21572
+ { 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
21573
  ]
21567
21574
  };
21568
21575
  function literalText(node, getText) {
@@ -21623,7 +21630,7 @@ var prefer_whole_object_assertion_default = createRule({
21623
21630
  },
21624
21631
  messages: {
21625
21632
  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 ])`."
21633
+ 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
21634
  },
21628
21635
  schema: []
21629
21636
  },
@@ -21689,7 +21696,7 @@ var prefer_whole_object_assertion_default = createRule({
21689
21696
  data: { count: String(run.length), receiver: receiverText }
21690
21697
  });
21691
21698
  }
21692
- function reportIndexRun(run) {
21699
+ function reportIndexRun(run, before, after) {
21693
21700
  const first = run[0];
21694
21701
  if (first === void 0 || !ARRAY_MATCHERS.has(first.matcher)) {
21695
21702
  return;
@@ -21704,6 +21711,7 @@ var prefer_whole_object_assertion_default = createRule({
21704
21711
  if (indices.size !== run.length || Math.max(...indices) !== run.length - 1) {
21705
21712
  return;
21706
21713
  }
21714
+ if (isExactLengthAssertion(before, first.receiver) || isExactLengthAssertion(after, first.receiver)) return;
21707
21715
  context.report({
21708
21716
  node: first.statement,
21709
21717
  messageId: "assertArrayOnce",
@@ -21717,18 +21725,19 @@ var prefer_whole_object_assertion_default = createRule({
21717
21725
  }
21718
21726
  function checkBody(body2) {
21719
21727
  let run = [];
21728
+ let start = 0;
21720
21729
  const flush = () => {
21721
21730
  const first = run[0];
21722
21731
  if (first !== void 0 && run.length >= MIN_RUN_LENGTH) {
21723
21732
  if (first.key.kind === "property") {
21724
21733
  reportPropertyRun(run);
21725
21734
  } else {
21726
- reportIndexRun(run);
21735
+ reportIndexRun(run, body2[start - 1], body2[start + run.length]);
21727
21736
  }
21728
21737
  }
21729
21738
  run = [];
21730
21739
  };
21731
- for (const statement of body2) {
21740
+ for (const [index, statement] of body2.entries()) {
21732
21741
  const assertion = parseAssertion(statement);
21733
21742
  const previous = run.at(-1);
21734
21743
  if (assertion !== null && previous !== void 0 && previous.key.kind === assertion.key.kind && sourceCode.getText(previous.receiver) === sourceCode.getText(assertion.receiver)) {
@@ -21738,6 +21747,7 @@ var prefer_whole_object_assertion_default = createRule({
21738
21747
  flush();
21739
21748
  if (assertion !== null) {
21740
21749
  run = [assertion];
21750
+ start = index;
21741
21751
  }
21742
21752
  }
21743
21753
  flush();
@@ -21783,14 +21793,26 @@ var prefer_whole_object_assertion_default = createRule({
21783
21793
  return assertionExpectation(statement, call, receiver, key, matcher);
21784
21794
  }
21785
21795
  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;
21796
+ return runtimeTestFrameworkName(sourceCode, callee, "assertion") === "expect";
21797
+ }
21798
+ function isExactLengthAssertion(statement, receiver) {
21799
+ if (statement?.type !== import_utils118.AST_NODE_TYPES.ExpressionStatement || statement.expression.type !== import_utils118.AST_NODE_TYPES.CallExpression) return false;
21800
+ const call = statement.expression;
21801
+ const callee = unwrapExpression(call.callee);
21802
+ if (callee.type !== import_utils118.AST_NODE_TYPES.MemberExpression || call.arguments.length !== 1) return false;
21803
+ const matcher = import_utils118.ASTUtils.getPropertyName(callee);
21804
+ const assertion = unwrapExpression(callee.object);
21805
+ if (assertion.type !== import_utils118.AST_NODE_TYPES.CallExpression || assertion.arguments.length !== 1) return false;
21806
+ const expect = unwrapExpression(assertion.callee);
21807
+ if (expect.type !== import_utils118.AST_NODE_TYPES.Identifier || !isTestExpect(expect)) return false;
21808
+ const expected = call.arguments[0] === void 0 ? void 0 : unwrapExpression(call.arguments[0]);
21809
+ if (expected?.type !== import_utils118.AST_NODE_TYPES.Literal || typeof expected.value !== "number" || !Number.isInteger(expected.value) || expected.value < 0) return false;
21810
+ let actual = assertion.arguments[0] === void 0 ? void 0 : unwrapExpression(assertion.arguments[0]);
21811
+ if (matcher !== "toHaveLength") {
21812
+ 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;
21813
+ actual = unwrapExpression(actual.object);
21814
+ }
21815
+ return actual !== void 0 && sourceCode.getText(actual) === sourceCode.getText(receiver);
21794
21816
  }
21795
21817
  function assertionExpectation(statement, call, receiver, key, matcher) {
21796
21818
  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.17.0",
5
5
  packageManager: "npm@12.2.0",
6
6
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
7
7
  type: "module",
@@ -36,12 +36,12 @@ var package_default = {
36
36
  scripts: {
37
37
  build: "tsup",
38
38
  dogfood: "npm run build && npm run dogfood:built",
39
- lint: "eslint . ../../.github/scripts ../standards/src ../standards/eslint.config.mjs --max-warnings 0",
39
+ lint: "eslint . ../../.github/scripts ../standards/src ../standards/eslint.config.mts --max-warnings 0",
40
40
  prepack: "npm run build && npm run verify-package",
41
41
  test: "vitest run",
42
- typecheck: "tsc --noEmit",
42
+ typecheck: "tsc --noEmit && tsc --project tsconfig.tools.json",
43
43
  "verify-package": `node -e "const fs=require('node:fs'),p=require('./package.json'),walk=x=>typeof x==='string'?[x]:x&&typeof x==='object'?Object.values(x).flatMap(walk):[];for(const file of new Set([p.main,p.module,p.types,...walk(p.exports)].filter(Boolean).map(x=>x.startsWith('./')?x.slice(2):x))){if(!file.startsWith('dist/'))throw new Error('export must live under dist: '+file);const stat=fs.statSync(file);if(!stat.isFile()||stat.size===0)throw new Error('missing or empty export: '+file)}"`,
44
- "dogfood:built": `eslint --config eslint.dogfood.config.mjs src tests eslint.config.mjs eslint.dogfood.config.mjs module-constant-naming-options.mjs tsup.config.ts vitest.config.ts --max-warnings 0 && node -e "import('./dist/index.js').then(({default:p}) => console.log('dogfood: '+Object.keys(p.rules).length+' TypeScript rules, 0 diagnostics'))"`
44
+ "dogfood:built": `eslint --config eslint.dogfood.config.ts src tests scripts eslint-rule-setting.ts eslint.config.ts eslint.dogfood.config.ts module-constant-naming-options.ts tsup.config.ts vitest.config.ts --max-warnings 0 && node -e "import('./dist/index.js').then(({default:p}) => console.log('dogfood: '+Object.keys(p.rules).length+' TypeScript rules, 0 diagnostics'))"`
45
45
  },
46
46
  keywords: [
47
47
  "eslint",
@@ -91,7 +91,8 @@ var package_default = {
91
91
  typescript: "npm:@typescript/typescript6@6.0.2",
92
92
  "typescript-eslint": "^8.71.2",
93
93
  vitest: "5.0.4",
94
- zod: "4.6.7"
94
+ zod: "4.6.7",
95
+ jiti: "2.7.0"
95
96
  },
96
97
  peerDependencies: {
97
98
  eslint: ">=9.39.4",
@@ -13628,26 +13629,30 @@ var no_string_concat_in_loop_default = createRule({
13628
13629
  import { AST_NODE_TYPES as AST_NODE_TYPES61, ASTUtils as ASTUtils37 } from "@typescript-eslint/utils";
13629
13630
  var TEST_MODULES = /* @__PURE__ */ new Set(["vitest", "@jest/globals", "bun:test"]);
13630
13631
  var TEST_NAMES2 = /* @__PURE__ */ new Set(["it", "test"]);
13631
- var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "fails", "failing", "todo"]);
13632
+ var MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "concurrent", "sequential", "fails", "failing", "todo"]);
13632
13633
  var NO_DUPLICATE_TEST_CASE_DOCUMENTATION = {
13633
13634
  defaultLevel: "error",
13634
- summary: "Report repeated literal rows in framework test.each or it.each tables.",
13635
+ summary: "Report repeated literal rows in framework each tables and Vitest for tables.",
13635
13636
  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
13637
  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
13638
  category: "testing",
13638
13639
  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.",
13640
+ "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.",
13641
+ "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
13642
  "The rule reports duplicate values, not runtime equivalence. There is no autofix because repeated invocations may exercise shared state or concurrency."
13642
13643
  ],
13643
13644
  examples: [
13644
13645
  { 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 }
13646
+ { 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 },
13647
+ { 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 },
13648
+ { 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
13649
  ]
13647
13650
  };
13648
- function frameworkEach(node, sourceCode) {
13651
+ function frameworkTable(node, sourceCode) {
13649
13652
  let callee = unwrapExpression(node.callee);
13650
- if (callee.type !== AST_NODE_TYPES61.MemberExpression || ASTUtils37.getPropertyName(callee) !== "each") return false;
13653
+ if (callee.type !== AST_NODE_TYPES61.MemberExpression) return false;
13654
+ const method = ASTUtils37.getPropertyName(callee);
13655
+ if (method !== "each" && method !== "for") return false;
13651
13656
  callee = unwrapExpression(callee.object);
13652
13657
  while (callee.type === AST_NODE_TYPES61.MemberExpression) {
13653
13658
  const name = ASTUtils37.getPropertyName(callee);
@@ -13656,12 +13661,12 @@ function frameworkEach(node, sourceCode) {
13656
13661
  }
13657
13662
  if (callee.type !== AST_NODE_TYPES61.Identifier) return false;
13658
13663
  const variable = ASTUtils37.findVariable(sourceCode.getScope(callee), callee.name);
13659
- if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name);
13664
+ if (variable === null || variable.defs.length === 0) return TEST_NAMES2.has(callee.name) && !variable?.references.some((reference) => reference.isWrite());
13660
13665
  return variable.defs.length === 1 && variable.defs.every((definition) => {
13661
- if (definition.node.type !== AST_NODE_TYPES61.ImportSpecifier) return false;
13666
+ if (definition.node.type !== AST_NODE_TYPES61.ImportSpecifier || definition.node.importKind === "type") return false;
13662
13667
  const imported = definition.node.imported;
13663
13668
  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);
13669
+ 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
13670
  });
13666
13671
  }
13667
13672
  function table(node, sourceCode) {
@@ -13675,7 +13680,7 @@ function table(node, sourceCode) {
13675
13680
  if (reference.init) return true;
13676
13681
  if (reference.identifier.type !== AST_NODE_TYPES61.Identifier) return false;
13677
13682
  const use = outerExpression(reference.identifier);
13678
- return use.parent.type === AST_NODE_TYPES61.CallExpression && use.parent.arguments[0] === use && frameworkEach(use.parent, sourceCode);
13683
+ return use.parent.type === AST_NODE_TYPES61.CallExpression && use.parent.arguments[0] === use && frameworkTable(use.parent, sourceCode);
13679
13684
  })) return null;
13680
13685
  const initializer = unwrapExpression(definition.node.init);
13681
13686
  return initializer.type === AST_NODE_TYPES61.ArrayExpression ? initializer : null;
@@ -13705,7 +13710,7 @@ function objectKey(node) {
13705
13710
  const fields = [];
13706
13711
  for (const property of node.properties) {
13707
13712
  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);
13713
+ const key = ASTUtils37.getPropertyName(property);
13709
13714
  const value = literalKey(property.value);
13710
13715
  if (key === null || value === null) return null;
13711
13716
  fields.push(JSON.stringify([key, value]));
@@ -13728,7 +13733,7 @@ var no_duplicate_test_case_default = createRule({
13728
13733
  return {
13729
13734
  CallExpression(node) {
13730
13735
  const callee = unwrapExpression(node.callee);
13731
- if (callee.type !== AST_NODE_TYPES61.CallExpression || !frameworkEach(callee, context.sourceCode)) return;
13736
+ if (callee.type !== AST_NODE_TYPES61.CallExpression || !frameworkTable(callee, context.sourceCode)) return;
13732
13737
  const input = callee.arguments[0];
13733
13738
  if (callee.arguments.length !== 1 || input === void 0) return;
13734
13739
  const cases = table(input, context.sourceCode);
@@ -21567,10 +21572,12 @@ var PREFER_WHOLE_OBJECT_ASSERTION_DOCUMENTATION = {
21567
21572
  category: "testing",
21568
21573
  aliases: ["strict-test-assertions"],
21569
21574
  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."],
21575
+ 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
21576
  examples: [
21572
21577
  { 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 }
21578
+ { 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 },
21579
+ { 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 },
21580
+ { 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
21581
  ]
21575
21582
  };
21576
21583
  function literalText(node, getText) {
@@ -21631,7 +21638,7 @@ var prefer_whole_object_assertion_default = createRule({
21631
21638
  },
21632
21639
  messages: {
21633
21640
  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 ])`."
21641
+ 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
21642
  },
21636
21643
  schema: []
21637
21644
  },
@@ -21697,7 +21704,7 @@ var prefer_whole_object_assertion_default = createRule({
21697
21704
  data: { count: String(run.length), receiver: receiverText }
21698
21705
  });
21699
21706
  }
21700
- function reportIndexRun(run) {
21707
+ function reportIndexRun(run, before, after) {
21701
21708
  const first = run[0];
21702
21709
  if (first === void 0 || !ARRAY_MATCHERS.has(first.matcher)) {
21703
21710
  return;
@@ -21712,6 +21719,7 @@ var prefer_whole_object_assertion_default = createRule({
21712
21719
  if (indices.size !== run.length || Math.max(...indices) !== run.length - 1) {
21713
21720
  return;
21714
21721
  }
21722
+ if (isExactLengthAssertion(before, first.receiver) || isExactLengthAssertion(after, first.receiver)) return;
21715
21723
  context.report({
21716
21724
  node: first.statement,
21717
21725
  messageId: "assertArrayOnce",
@@ -21725,18 +21733,19 @@ var prefer_whole_object_assertion_default = createRule({
21725
21733
  }
21726
21734
  function checkBody(body2) {
21727
21735
  let run = [];
21736
+ let start = 0;
21728
21737
  const flush = () => {
21729
21738
  const first = run[0];
21730
21739
  if (first !== void 0 && run.length >= MIN_RUN_LENGTH) {
21731
21740
  if (first.key.kind === "property") {
21732
21741
  reportPropertyRun(run);
21733
21742
  } else {
21734
- reportIndexRun(run);
21743
+ reportIndexRun(run, body2[start - 1], body2[start + run.length]);
21735
21744
  }
21736
21745
  }
21737
21746
  run = [];
21738
21747
  };
21739
- for (const statement of body2) {
21748
+ for (const [index, statement] of body2.entries()) {
21740
21749
  const assertion = parseAssertion(statement);
21741
21750
  const previous = run.at(-1);
21742
21751
  if (assertion !== null && previous !== void 0 && previous.key.kind === assertion.key.kind && sourceCode.getText(previous.receiver) === sourceCode.getText(assertion.receiver)) {
@@ -21746,6 +21755,7 @@ var prefer_whole_object_assertion_default = createRule({
21746
21755
  flush();
21747
21756
  if (assertion !== null) {
21748
21757
  run = [assertion];
21758
+ start = index;
21749
21759
  }
21750
21760
  }
21751
21761
  flush();
@@ -21791,14 +21801,26 @@ var prefer_whole_object_assertion_default = createRule({
21791
21801
  return assertionExpectation(statement, call, receiver, key, matcher);
21792
21802
  }
21793
21803
  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;
21804
+ return runtimeTestFrameworkName(sourceCode, callee, "assertion") === "expect";
21805
+ }
21806
+ function isExactLengthAssertion(statement, receiver) {
21807
+ if (statement?.type !== AST_NODE_TYPES101.ExpressionStatement || statement.expression.type !== AST_NODE_TYPES101.CallExpression) return false;
21808
+ const call = statement.expression;
21809
+ const callee = unwrapExpression(call.callee);
21810
+ if (callee.type !== AST_NODE_TYPES101.MemberExpression || call.arguments.length !== 1) return false;
21811
+ const matcher = ASTUtils63.getPropertyName(callee);
21812
+ const assertion = unwrapExpression(callee.object);
21813
+ if (assertion.type !== AST_NODE_TYPES101.CallExpression || assertion.arguments.length !== 1) return false;
21814
+ const expect = unwrapExpression(assertion.callee);
21815
+ if (expect.type !== AST_NODE_TYPES101.Identifier || !isTestExpect(expect)) return false;
21816
+ const expected = call.arguments[0] === void 0 ? void 0 : unwrapExpression(call.arguments[0]);
21817
+ if (expected?.type !== AST_NODE_TYPES101.Literal || typeof expected.value !== "number" || !Number.isInteger(expected.value) || expected.value < 0) return false;
21818
+ let actual = assertion.arguments[0] === void 0 ? void 0 : unwrapExpression(assertion.arguments[0]);
21819
+ if (matcher !== "toHaveLength") {
21820
+ if (matcher === null || !MERGEABLE_MATCHERS.has(matcher) || actual?.type !== AST_NODE_TYPES101.MemberExpression || actual.optional || ASTUtils63.getPropertyName(actual) !== "length") return false;
21821
+ actual = unwrapExpression(actual.object);
21822
+ }
21823
+ return actual !== void 0 && sourceCode.getText(actual) === sourceCode.getText(receiver);
21802
21824
  }
21803
21825
  function assertionExpectation(statement, call, receiver, key, matcher) {
21804
21826
  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.17.0",
4
4
  "packageManager": "npm@12.2.0",
5
5
  "description": "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
6
6
  "type": "module",
@@ -35,12 +35,12 @@
35
35
  "scripts": {
36
36
  "build": "tsup",
37
37
  "dogfood": "npm run build && npm run dogfood:built",
38
- "lint": "eslint . ../../.github/scripts ../standards/src ../standards/eslint.config.mjs --max-warnings 0",
38
+ "lint": "eslint . ../../.github/scripts ../standards/src ../standards/eslint.config.mts --max-warnings 0",
39
39
  "prepack": "npm run build && npm run verify-package",
40
40
  "test": "vitest run",
41
- "typecheck": "tsc --noEmit",
41
+ "typecheck": "tsc --noEmit && tsc --project tsconfig.tools.json",
42
42
  "verify-package": "node -e \"const fs=require('node:fs'),p=require('./package.json'),walk=x=>typeof x==='string'?[x]:x&&typeof x==='object'?Object.values(x).flatMap(walk):[];for(const file of new Set([p.main,p.module,p.types,...walk(p.exports)].filter(Boolean).map(x=>x.startsWith('./')?x.slice(2):x))){if(!file.startsWith('dist/'))throw new Error('export must live under dist: '+file);const stat=fs.statSync(file);if(!stat.isFile()||stat.size===0)throw new Error('missing or empty export: '+file)}\"",
43
- "dogfood:built": "eslint --config eslint.dogfood.config.mjs src tests eslint.config.mjs eslint.dogfood.config.mjs module-constant-naming-options.mjs tsup.config.ts vitest.config.ts --max-warnings 0 && node -e \"import('./dist/index.js').then(({default:p}) => console.log('dogfood: '+Object.keys(p.rules).length+' TypeScript rules, 0 diagnostics'))\""
43
+ "dogfood:built": "eslint --config eslint.dogfood.config.ts src tests scripts eslint-rule-setting.ts eslint.config.ts eslint.dogfood.config.ts module-constant-naming-options.ts tsup.config.ts vitest.config.ts --max-warnings 0 && node -e \"import('./dist/index.js').then(({default:p}) => console.log('dogfood: '+Object.keys(p.rules).length+' TypeScript rules, 0 diagnostics'))\""
44
44
  },
45
45
  "keywords": [
46
46
  "eslint",
@@ -90,7 +90,8 @@
90
90
  "typescript": "npm:@typescript/typescript6@6.0.2",
91
91
  "typescript-eslint": "^8.71.2",
92
92
  "vitest": "5.0.4",
93
- "zod": "4.6.7"
93
+ "zod": "4.6.7",
94
+ "jiti": "2.7.0"
94
95
  },
95
96
  "peerDependencies": {
96
97
  "eslint": ">=9.39.4",