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