@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 +54 -32
- package/dist/index.js +54 -32
- package/package.json +6 -5
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
13670
|
+
function frameworkTable(node, sourceCode) {
|
|
13668
13671
|
let callee = unwrapExpression(node.callee);
|
|
13669
|
-
if (callee.type !== import_utils75.AST_NODE_TYPES.MemberExpression
|
|
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 &&
|
|
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 =
|
|
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 || !
|
|
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}}]`
|
|
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
|
-
|
|
21787
|
-
|
|
21788
|
-
|
|
21789
|
-
|
|
21790
|
-
|
|
21791
|
-
|
|
21792
|
-
|
|
21793
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
13651
|
+
function frameworkTable(node, sourceCode) {
|
|
13649
13652
|
let callee = unwrapExpression(node.callee);
|
|
13650
|
-
if (callee.type !== AST_NODE_TYPES61.MemberExpression
|
|
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 &&
|
|
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 =
|
|
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 || !
|
|
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}}]`
|
|
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
|
-
|
|
21795
|
-
|
|
21796
|
-
|
|
21797
|
-
|
|
21798
|
-
|
|
21799
|
-
|
|
21800
|
-
|
|
21801
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|