JS-1687 Rule S5906 The most specific assertion should be used#7118
JS-1687 Rule S5906 The most specific assertion should be used#7118sonar-nigel[bot] wants to merge 19 commits into
Conversation
Detects generic test assertions that should use more specific matchers.
Fix the Playwright locator detector to prove a candidate locator expression is a call expression before using the method-call helper. This preserves the existing locator matching behavior while satisfying the bridge TypeScript compile checks.
Detect generic test assertions where a more specific assertion should be used, with issue-only reporting aligned to RSPEC metadata.
Marked the ruling entries for S5906 as intentional reports because they match the supported generic assertion patterns for nullish checks, length checks, boolean expression assertions, Chai assertions, and Playwright locator getters. Added unit coverage for ruling-observed Chai typeof equality and Playwright getByTestId inputValue assertions to keep these accepted reports explicit.
Refactor S5906 assertion suggestion helpers into a focused helper module so rule.ts stays below the file-length threshold and the boolean-expression suggestion logic is split into low-complexity functions. Also replace the reported null check, repeated positional assignments, and magic number with clearer optional chaining, destructuring, and a named constant. No NOSONAR suppressions were added.
S5906/rule.ts Comment: Instead of calling `importsOrDependsOnModule` again here, we should store the assertion style family in the `AssertionBase` type as a new prop called `style`, where this information is already available. | Family | Raw shape / discriminator | |---|---| | `jest-like` | Jest / Vitest generic `expect(...).toBe()/toEqual()/toStrictEqual()` style call | | `jasmine` | Jasmine `expect(...).toBe()/toEqual()/withContext(...).toBe()` style call | | `chai-bdd` | `expect(...).equal()/eql()/...` or `value.should.equal()/...` | | `chai-assert` | `assert.strictEqual()/notStrictEqual()/deepEqual()/...` member call | | `cypress` | `cy....should('equal', ...)` / `.and('equal', ...)` rooted in `cy` |
|
Here are verified pattern tables that should be supported, along with suggestions, make sure that the implementation respects these tables. 3.1 Jest / VitestThese libraries are treated as one expect-style family for the covered matcher names. For primitive
Comparator mapping uses the direct inverse matcher for
Not in scope: 3.2 Chai BDD —
|
| Noncompliant | Compliant | Suggestion |
|---|---|---|
expect(x).to.equal(null) |
expect(x).to.be.null |
✓ |
expect(x).to.equal(undefined) |
expect(x).to.be.undefined |
✓ |
expect(x.length).to.equal(n) |
expect(x).to.have.lengthOf(n) |
✓ |
expect(x.length).to.not.equal(n) |
expect(x).to.not.have.lengthOf(n) |
✓ |
expect(x instanceof C).to.equal(true) |
expect(x).to.be.instanceOf(C) |
✓ |
expect(x instanceof C).to.equal(false) |
expect(x).to.not.be.instanceOf(C) |
✓ |
expect(a > b).to.equal(true) |
expect(a).to.be.above(b) |
✓ |
expect(a > b).to.equal(false) |
expect(a).to.be.at.most(b) |
✓ |
expect(text.includes(substr)).to.equal(true) |
expect(text).to.include(substr) |
✓ (trusted string receiver only) |
expect(text.includes(substr)).to.equal(false) |
expect(text).to.not.include(substr) |
✓ (trusted string receiver only) |
x.should.equal(null) |
x.should.be.null |
✓ |
x.should.equal(undefined) |
x.should.be.undefined |
✓ |
Comparator rewrites follow the same rule: exact boolean comparison on a > b, a >= b,
a < b, or a <= b maps to the direct Chai chainer for the desired boolean:
>→above/at.most>=→at.least/below<→below/at.least<=→at.most/above
includes() rows apply only when isTrustedStringIncludesCall(...) passes. Non-string receivers,
including arrays and tuples, stay silent rather than reporting without a safe rewrite.
Chai exact-equality forms on NaN are intentionally out of scope in v1. equal(NaN) /
not.equal(NaN) are not semantically equivalent to .be.NaN / .not.be.NaN.
Note: .to.be.null, .to.be.undefined, and .to.be.NaN are property accesses
(MemberExpression), not calls. Suggestions must not add ().
3.3 Chai Assert style
Verified against https://www.chaijs.com/api/assert/.
assert.strictEqual(x, null) is equivalent to assert.isNull(x) for the primitive null case.
| Noncompliant | Compliant | Suggestion |
|---|---|---|
assert.strictEqual(x, null) |
assert.isNull(x) |
✓ |
assert.notStrictEqual(x, null) |
assert.isNotNull(x) |
✓ |
assert.strictEqual(x, undefined) |
assert.isUndefined(x) |
✓ |
assert.notStrictEqual(x, undefined) |
assert.isDefined(x) |
✓ |
assert.strictEqual(x.length, n) |
assert.lengthOf(x, n) |
✓ |
assert.strictEqual(x instanceof C, true) |
assert.instanceOf(x, C) |
✓ |
assert.strictEqual(x instanceof C, false) |
assert.notInstanceOf(x, C) |
✓ |
assert.strictEqual(a > b, true) |
assert.isAbove(a, b) |
✓ |
assert.strictEqual(a > b, false) |
assert.isAtMost(a, b) |
✓ |
assert.strictEqual(text.includes(substr), true) |
assert.include(text, substr) |
✓ (trusted string receiver only) |
assert.strictEqual(text.includes(substr), false) |
assert.notInclude(text, substr) |
✓ (trusted string receiver only) |
Comparator mapping follows the direct assert API methods:
>→isAbove/isAtMost>=→isAtLeast/isBelow<→isBelow/isAtLeast<=→isAtMost/isAbove
strictEqual and notStrictEqual both flow through the same boolean-outcome logic. equal and
notEqual stay out of scope because they are loose comparisons. For example,
assert.notStrictEqual(a > b, false) still rewrites to assert.isAbove(a, b).
Chai assert exact-equality forms on NaN are intentionally out of scope in v1. strictEqual /
notStrictEqual use strict equality semantics, which are not equivalent to assert.isNaN(...).
includes() rows apply only when isTrustedStringIncludesCall(...) passes. Non-string receivers,
including arrays and tuples, stay silent.
Direct exact-boolean helpers such as assert.isTrue(x instanceof C) are deferred in v1; see §8.
3.4 Cypress
Verified against https://docs.cypress.io/app/references/assertions.
Cypress wraps Chai BDD, but v1 limits suggestions to the null / undefined cases because those are
the only covered rewrites that keep the wrapped subject unchanged.
| Noncompliant | Compliant | Suggestion |
|---|---|---|
cy.wrap(x).should('equal', null) |
cy.wrap(x).should('be.null') |
✓ |
cy.wrap(x).should('deep.equal', null) |
cy.wrap(x).should('be.null') |
✓ |
cy.wrap(x).should('equal', undefined) |
cy.wrap(x).should('be.undefined') |
✓ |
cy.wrap(x).should('not.equal', undefined) |
cy.wrap(x).should('not.be.undefined') |
✓ |
NaN is excluded: Cypress does not expose a dedicated be.NaN string chainer in the same
style as the supported null / undefined forms.
3.5 Playwright
Verified against https://playwright.dev/docs/api/class-locator and
https://playwright.dev/docs/api/class-locatorassertions.
Web-first locator assertions auto-retry; raw locator getters do not. These are issue-only in v1,
with no suggestion.
Generic Playwright expect(value, message) rewrites are deferred from v1. JS-1687 only covers the
locator-specific patterns below.
| Noncompliant | Compliant | Suggestion |
|---|---|---|
expect(await loc.isVisible()).toBe(true) |
await expect(loc).toBeVisible() |
issue only |
expect(await loc.isVisible()).toBe(false) |
await expect(loc).toBeHidden() |
issue only |
expect(await loc.isHidden()).toBe(true) |
await expect(loc).toBeHidden() |
issue only |
expect(await loc.isEnabled()).toBe(false) |
await expect(loc).toBeDisabled() |
issue only |
expect(await loc.count()).toBe(3) |
await expect(loc).toHaveCount(3) |
issue only |
expect(await loc.count()).not.toBe(3) |
await expect(loc).not.toHaveCount(3) |
issue only |
expect(await loc.inputValue()).toEqual('abc') |
await expect(loc).toHaveValue('abc') |
issue only |
expect(await loc.inputValue()).not.toStrictEqual('abc') |
await expect(loc).not.toHaveValue('abc') |
issue only |
Step 4 — Messages
Use one issue message and one suggestion message:
const messages = {
preferSpecificAssertion: 'Use the more specific assertion {{suggestion}}.',
quickfix: 'Replace with {{suggestion}}.',
};Example rendered issue messages:
| Pattern | Message |
|---|---|
| Jest null | Use the more specific assertion toBeNull(). |
| Jest not-undefined | Use the more specific assertion toBeDefined(). |
| Jest comparator false | Use the more specific assertion toBeLessThanOrEqual(b). |
| Chai null | Use the more specific assertion .to.be.null. |
| Chai length | Use the more specific assertion .to.have.lengthOf(n). |
| Chai assert null | Use the more specific assertion assert.isNull(x). |
| Playwright state getter | Use the more specific assertion await expect(locator).toBeVisible(). |
| Playwright count getter | Use the more specific assertion await expect(locator).toHaveCount(3). |
Step 5 — Suggestion computation
Reporting and suggestion generation are separate:
- A safe issue may be reported without a suggestion.
- Do not replace the whole assertion blindly.
- Preserve untouched source fragments, especially:
- optional Chai
expect(received, message)message arguments; - Chai assert trailing message arguments;
- aliased assert objects such as
a.strictEqual(...); - existing
.notwhen the replacement still uses it.
- optional Chai
Helper: src(node) = context.sourceCode.getText(node).
For boolean-shape rewrites, derive the semantic target first:
const wantsTruth = assertion.negated ? !expected.value : expected.value;Comparator rewrites then choose the direct matcher/chainer/method from
(actual.operator, wantsTruth) instead of defaulting to “same matcher under negation”.
5.1 Jest / Vitest suggestions
Jest / Vitest expect() has no optional message argument, so full-node replacement is acceptable.
Playwright is intentionally excluded from this suggestion path in v1.
| Category | Suggestion template |
|---|---|
| null | ${src(node.callee.object)}.toBeNull() |
| undefined (positive) | ${src(node.callee.object)}.toBeUndefined() |
| undefined (negated) | expect(${src(actual)}).toBeDefined() |
| NaN | ${src(node.callee.object)}.toBeNaN() |
| length | expect(${src(actual.object)}).${assertion.negated ? 'not.' : ''}toHaveLength(${src(expected)}) |
comparator > |
expect(${src(actual.left)}).${wantsTruth ? 'toBeGreaterThan' : 'toBeLessThanOrEqual'}(${src(actual.right)}) |
comparator >= |
expect(${src(actual.left)}).${wantsTruth ? 'toBeGreaterThanOrEqual' : 'toBeLessThan'}(${src(actual.right)}) |
comparator < |
expect(${src(actual.left)}).${wantsTruth ? 'toBeLessThan' : 'toBeGreaterThanOrEqual'}(${src(actual.right)}) |
comparator <= |
expect(${src(actual.left)}).${wantsTruth ? 'toBeLessThanOrEqual' : 'toBeGreaterThan'}(${src(actual.right)}) |
instanceof |
expect(${src(actual.left)}).${wantsTruth ? '' : 'not.'}toBeInstanceOf(${src(actual.right)}) |
includes() (trusted string receiver) |
expect(${src(actual.callee.object)}).${wantsTruth ? '' : 'not.'}toContain(${src(actual.arguments[0])}) |
Examples:
expect(a > b).toBe(false)→expect(a).toBeLessThanOrEqual(b)expect(a > b).not.toBe(false)→expect(a).toBeGreaterThan(b)
5.2 Chai BDD suggestions
Do not rebuild the full node from scratch if the original source contains an optional Chai
message argument.
Rules:
- For null / undefined, reuse the original chain prefix so
expect(x, 'msg')is preserved:
expect(x, 'msg').to.equal(null)→expect(x, 'msg').to.be.null. - For length / comparator /
instanceof/includes(), rebuild theexpect(...)subject but
carrybase.arguments.slice(1)through to the newexpect(...)call. - For comparators, choose the direct Chai chainer from
(actual.operator, wantsTruth):
>→above/at.most,>=→at.least/below,<→below/at.least,
<=→at.most/above. - For
includes(), only offer a suggestion whenisTrustedStringIncludesCall(...)passes. - For
shouldstyle there is no message argument; rebuild from${src(subject)}.should. - If the existing chain uses unsupported modifiers that cannot be preserved exactly, report the
issue but omit the suggestion.
Examples:
expect(items.length, 'msg').to.equal(3)→expect(items, 'msg').to.have.lengthOf(3)expect(text.includes(x), 'msg').to.equal(false)→expect(text, 'msg').to.not.include(x)
5.3 Chai assert suggestions
Preserve both the assert object and trailing message arguments:
| Category | Suggestion template |
|---|---|
| null | ${src(assertObject)}.isNull(${src(actual)}${trailingArgs}) |
| not null | ${src(assertObject)}.isNotNull(${src(actual)}${trailingArgs}) |
| undefined | ${src(assertObject)}.isUndefined(${src(actual)}${trailingArgs}) |
| defined | ${src(assertObject)}.isDefined(${src(actual)}${trailingArgs}) |
| length | ${src(assertObject)}.lengthOf(${src(actual.object)}, ${src(expected)}${trailingArgs}) |
comparator > |
${src(assertObject)}.${wantsTruth ? 'isAbove' : 'isAtMost'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs}) |
comparator >= |
${src(assertObject)}.${wantsTruth ? 'isAtLeast' : 'isBelow'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs}) |
comparator < |
${src(assertObject)}.${wantsTruth ? 'isBelow' : 'isAtLeast'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs}) |
comparator <= |
${src(assertObject)}.${wantsTruth ? 'isAtMost' : 'isAbove'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs}) |
| instanceof (positive) | ${src(assertObject)}.instanceOf(${src(actual.left)}, ${src(actual.right)}${trailingArgs}) |
| instanceof (negative) | ${src(assertObject)}.notInstanceOf(${src(actual.left)}, ${src(actual.right)}${trailingArgs}) |
includes() (positive, trusted string receiver) |
${src(assertObject)}.include(${src(actual.callee.object)}, ${src(actual.arguments[0])}${trailingArgs}) |
includes() (negative, trusted string receiver) |
${src(assertObject)}.notInclude(${src(actual.callee.object)}, ${src(actual.arguments[0])}${trailingArgs}) |
Examples:
a.strictEqual(value, null, 'msg')→a.isNull(value, 'msg')assert.strictEqual(items.length, 3, 'msg')→assert.lengthOf(items, 3, 'msg')assert.strictEqual(a > b, false, 'msg')→assert.isAtMost(a, b, 'msg')
Suggestion guard:
- only offer a suggestion when the callee is a member expression on an assert object that can be
rewritten directly; - only offer a
lengthOf(...)suggestion for the positive semantic case; if the original assertion
means “does not have this length”, report nothing because Chai assert has no dedicated negative
lengthOfhelper; - only offer containment suggestions when the
.includes()receiver passes the trusted string
guard; - if the source shape cannot preserve the assert target unambiguously, report without suggestion.
5.4 Cypress suggestions
Only the null / undefined forms are in-scope for suggestions in v1.
| Pattern | Suggestion |
|---|---|
should('equal', null) |
replace first string arg with 'be.null' and remove the expected arg |
should('deep.equal', null) |
replace first string arg with 'be.null' and remove the expected arg |
should('equal', undefined) |
replace first string arg with 'be.undefined' and remove the expected arg |
should('not.equal', undefined) |
replace first string arg with 'not.be.undefined' and remove the expected arg |
If the Cypress call has additional arguments beyond the supported shape, report without suggestion.
5.5 Playwright
No suggestion in v1. Every Playwright locator rewrite changes both the awaited expression and the
assertion subject.
Covered issue-only patterns:
expect(await locator.isVisible()).toBe(true/false)and the other supported state getters- generic equality on
await locator.count()(toBe,toEqual,toStrictEqual, with optional.not) - generic equality on
await locator.inputValue()(toBe,toEqual,toStrictEqual, with optional.not)
Do not mirror .not. blindly. Derive the desired boolean/value after applying the original
negation first, then choose the dedicated locator assertion for that semantic target.
Examples:
expect(await locator.isVisible()).not.toBe(false)→await expect(locator).toBeVisible()expect(await locator.isChecked()).toBe(false)→await expect(locator).not.toBeChecked()expect(await locator.count()).not.toBe(3)→await expect(locator).not.toHaveCount(3)
Comment: Here are verified pattern tables that should be supported, along with suggestions, make sure that the implementation respects these tables. ### 3.1 Jest / Vitest These libraries are treated as one expect-style family for the covered matcher names. For primitive `null`, `undefined`, and `NaN`, the dedicated matchers are semantically equivalent to the generic equality forms covered here. The undefined-key caveat of `toEqual()` applies to object-vs-object structural comparison, not when the expected value is the primitive `undefined`. | Noncompliant | Compliant | Matchers that trigger | Suggestion | |---|---|---|---| | `expect(x).toBe(null)` | `expect(x).toBeNull()` | `toBe`, `toEqual`, `toStrictEqual` | ✓ | | `expect(x).not.toBe(undefined)` | `expect(x).toBeDefined()` | `not.toBe`, `not.toEqual`, `not.toStrictEqual` | ✓ | | `expect(x).toBe(NaN)` | `expect(x).toBeNaN()` | `toBe`, `toEqual`, `toStrictEqual` | ✓ | | `expect(x.length).toBe(n)` | `expect(x).toHaveLength(n)` | `toBe`, `toEqual`, `toStrictEqual` | ✓ | | `expect(x.length).not.toBe(n)` | `expect(x).not.toHaveLength(n)` | same | ✓ | | `expect(x instanceof C).toBe(true)` | `expect(x).toBeInstanceOf(C)` | strict boolean compare | ✓ | | `expect(x instanceof C).toBe(false)` | `expect(x).not.toBeInstanceOf(C)` | strict boolean compare | ✓ | | `expect(a > b).toBe(true)` | `expect(a).toBeGreaterThan(b)` | strict boolean compare | ✓ | | `expect(a > b).toBe(false)` | `expect(a).toBeLessThanOrEqual(b)` | strict boolean compare | ✓ | | `expect(text.includes(substr)).toBe(true)` | `expect(text).toContain(substr)` | strict boolean compare | ✓ (trusted string receiver only) | | `expect(text.includes(substr)).toBe(false)` | `expect(text).not.toContain(substr)` | strict boolean compare | ✓ (trusted string receiver only) | Comparator mapping uses the direct inverse matcher for `false`: - `>` → `toBeGreaterThan` / `toBeLessThanOrEqual` - `>=` → `toBeGreaterThanOrEqual` / `toBeLessThan` - `<` → `toBeLessThan` / `toBeGreaterThanOrEqual` - `<=` → `toBeLessThanOrEqual` / `toBeGreaterThan` `includes()` rows apply only when `isTrustedStringIncludesCall(...)` passes. Non-string receivers, including arrays and tuples, stay silent in v1. **Not in scope:** `toBeTruthy()` / `toBeFalsy()` variants for `instanceof`, comparators, and `includes()` — those are truthiness checks, not exact boolean equality checks. ### 3.2 Chai BDD — `expect` and `should` styles Verified against https://www.chaijs.com/api/bdd/. `.to.equal(null)` → `.to.be.null`: both use strict equality for the primitive case, and Chai’s BDD API exposes dedicated property chainers for `null` and `undefined`. | Noncompliant | Compliant | Suggestion | |---|---|---| | `expect(x).to.equal(null)` | `expect(x).to.be.null` | ✓ | | `expect(x).to.equal(undefined)` | `expect(x).to.be.undefined` | ✓ | | `expect(x.length).to.equal(n)` | `expect(x).to.have.lengthOf(n)` | ✓ | | `expect(x.length).to.not.equal(n)` | `expect(x).to.not.have.lengthOf(n)` | ✓ | | `expect(x instanceof C).to.equal(true)` | `expect(x).to.be.instanceOf(C)` | ✓ | | `expect(x instanceof C).to.equal(false)` | `expect(x).to.not.be.instanceOf(C)` | ✓ | | `expect(a > b).to.equal(true)` | `expect(a).to.be.above(b)` | ✓ | | `expect(a > b).to.equal(false)` | `expect(a).to.be.at.most(b)` | ✓ | | `expect(text.includes(substr)).to.equal(true)` | `expect(text).to.include(substr)` | ✓ (trusted string receiver only) | | `expect(text.includes(substr)).to.equal(false)` | `expect(text).to.not.include(substr)` | ✓ (trusted string receiver only) | | `x.should.equal(null)` | `x.should.be.null` | ✓ | | `x.should.equal(undefined)` | `x.should.be.undefined` | ✓ | Comparator rewrites follow the same rule: exact boolean comparison on `a > b`, `a >= b`, `a < b`, or `a <= b` maps to the direct Chai chainer for the desired boolean: - `>` → `above` / `at.most` - `>=` → `at.least` / `below` - `<` → `below` / `at.least` - `<=` → `at.most` / `above` `includes()` rows apply only when `isTrustedStringIncludesCall(...)` passes. Non-string receivers, including arrays and tuples, stay silent rather than reporting without a safe rewrite. Chai exact-equality forms on `NaN` are intentionally out of scope in v1. `equal(NaN)` / `not.equal(NaN)` are not semantically equivalent to `.be.NaN` / `.not.be.NaN`. Note: `.to.be.null`, `.to.be.undefined`, and `.to.be.NaN` are **property accesses** (`MemberExpression`), not calls. Suggestions must not add `()`. ### 3.3 Chai Assert style Verified against https://www.chaijs.com/api/assert/. `assert.strictEqual(x, null)` is equivalent to `assert.isNull(x)` for the primitive `null` case. | Noncompliant | Compliant | Suggestion | |---|---|---| | `assert.strictEqual(x, null)` | `assert.isNull(x)` | ✓ | | `assert.notStrictEqual(x, null)` | `assert.isNotNull(x)` | ✓ | | `assert.strictEqual(x, undefined)` | `assert.isUndefined(x)` | ✓ | | `assert.notStrictEqual(x, undefined)` | `assert.isDefined(x)` | ✓ | | `assert.strictEqual(x.length, n)` | `assert.lengthOf(x, n)` | ✓ | | `assert.strictEqual(x instanceof C, true)` | `assert.instanceOf(x, C)` | ✓ | | `assert.strictEqual(x instanceof C, false)` | `assert.notInstanceOf(x, C)` | ✓ | | `assert.strictEqual(a > b, true)` | `assert.isAbove(a, b)` | ✓ | | `assert.strictEqual(a > b, false)` | `assert.isAtMost(a, b)` | ✓ | | `assert.strictEqual(text.includes(substr), true)` | `assert.include(text, substr)` | ✓ (trusted string receiver only) | | `assert.strictEqual(text.includes(substr), false)` | `assert.notInclude(text, substr)` | ✓ (trusted string receiver only) | Comparator mapping follows the direct assert API methods: - `>` → `isAbove` / `isAtMost` - `>=` → `isAtLeast` / `isBelow` - `<` → `isBelow` / `isAtLeast` - `<=` → `isAtMost` / `isAbove` `strictEqual` and `notStrictEqual` both flow through the same boolean-outcome logic. `equal` and `notEqual` stay out of scope because they are loose comparisons. For example, `assert.notStrictEqual(a > b, false)` still rewrites to `assert.isAbove(a, b)`. Chai assert exact-equality forms on `NaN` are intentionally out of scope in v1. `strictEqual` / `notStrictEqual` use strict equality semantics, which are not equivalent to `assert.isNaN(...)`. `includes()` rows apply only when `isTrustedStringIncludesCall(...)` passes. Non-string receivers, including arrays and tuples, stay silent. Direct exact-boolean helpers such as `assert.isTrue(x instanceof C)` are deferred in v1; see §8. ### 3.4 Cypress Verified against https://docs.cypress.io/app/references/assertions. Cypress wraps Chai BDD, but v1 limits suggestions to the null / undefined cases because those are the only covered rewrites that keep the wrapped subject unchanged. | Noncompliant | Compliant | Suggestion | |---|---|---| | `cy.wrap(x).should('equal', null)` | `cy.wrap(x).should('be.null')` | ✓ | | `cy.wrap(x).should('deep.equal', null)` | `cy.wrap(x).should('be.null')` | ✓ | | `cy.wrap(x).should('equal', undefined)` | `cy.wrap(x).should('be.undefined')` | ✓ | | `cy.wrap(x).should('not.equal', undefined)` | `cy.wrap(x).should('not.be.undefined')` | ✓ | **NaN is excluded**: Cypress does not expose a dedicated `be.NaN` string chainer in the same style as the supported null / undefined forms. ### 3.5 Playwright Verified against https://playwright.dev/docs/api/class-locator and https://playwright.dev/docs/api/class-locatorassertions. Web-first locator assertions auto-retry; raw locator getters do not. These are issue-only in v1, with no suggestion. Generic Playwright `expect(value, message)` rewrites are deferred from v1. JS-1687 only covers the locator-specific patterns below. | Noncompliant | Compliant | Suggestion | |---|---|---| | `expect(await loc.isVisible()).toBe(true)` | `await expect(loc).toBeVisible()` | issue only | | `expect(await loc.isVisible()).toBe(false)` | `await expect(loc).toBeHidden()` | issue only | | `expect(await loc.isHidden()).toBe(true)` | `await expect(loc).toBeHidden()` | issue only | | `expect(await loc.isEnabled()).toBe(false)` | `await expect(loc).toBeDisabled()` | issue only | | `expect(await loc.count()).toBe(3)` | `await expect(loc).toHaveCount(3)` | issue only | | `expect(await loc.count()).not.toBe(3)` | `await expect(loc).not.toHaveCount(3)` | issue only | | `expect(await loc.inputValue()).toEqual('abc')` | `await expect(loc).toHaveValue('abc')` | issue only | | `expect(await loc.inputValue()).not.toStrictEqual('abc')` | `await expect(loc).not.toHaveValue('abc')` | issue only | --- ## Step 4 — Messages Use one issue message and one suggestion message: ```typescript const messages = { preferSpecificAssertion: 'Use the more specific assertion {{suggestion}}.', quickfix: 'Replace with {{suggestion}}.', }; ``` Example rendered issue messages: | Pattern | Message | |---|---| | Jest null | `Use the more specific assertion toBeNull().` | | Jest not-undefined | `Use the more specific assertion toBeDefined().` | | Jest comparator false | `Use the more specific assertion toBeLessThanOrEqual(b).` | | Chai null | `Use the more specific assertion .to.be.null.` | | Chai length | `Use the more specific assertion .to.have.lengthOf(n).` | | Chai assert null | `Use the more specific assertion assert.isNull(x).` | | Playwright state getter | `Use the more specific assertion await expect(locator).toBeVisible().` | | Playwright count getter | `Use the more specific assertion await expect(locator).toHaveCount(3).` | --- ## Step 5 — Suggestion computation Reporting and suggestion generation are separate: - A safe issue may be reported **without** a suggestion. - Do **not** replace the whole assertion blindly. - Preserve untouched source fragments, especially: - optional Chai `expect(received, message)` message arguments; - Chai assert trailing message arguments; - aliased assert objects such as `a.strictEqual(...)`; - existing `.not` when the replacement still uses it. Helper: `src(node)` = `context.sourceCode.getText(node)`. For boolean-shape rewrites, derive the semantic target first: ```typescript const wantsTruth = assertion.negated ? !expected.value : expected.value; ``` Comparator rewrites then choose the direct matcher/chainer/method from `(actual.operator, wantsTruth)` instead of defaulting to “same matcher under negation”. ### 5.1 Jest / Vitest suggestions Jest / Vitest `expect()` has no optional message argument, so full-node replacement is acceptable. Playwright is intentionally excluded from this suggestion path in v1. | Category | Suggestion template | |---|---| | null | `${src(node.callee.object)}.toBeNull()` | | undefined (positive) | `${src(node.callee.object)}.toBeUndefined()` | | undefined (negated) | `expect(${src(actual)}).toBeDefined()` | | NaN | `${src(node.callee.object)}.toBeNaN()` | | length | `expect(${src(actual.object)}).${assertion.negated ? 'not.' : ''}toHaveLength(${src(expected)})` | | comparator `>` | `expect(${src(actual.left)}).${wantsTruth ? 'toBeGreaterThan' : 'toBeLessThanOrEqual'}(${src(actual.right)})` | | comparator `>=` | `expect(${src(actual.left)}).${wantsTruth ? 'toBeGreaterThanOrEqual' : 'toBeLessThan'}(${src(actual.right)})` | | comparator `<` | `expect(${src(actual.left)}).${wantsTruth ? 'toBeLessThan' : 'toBeGreaterThanOrEqual'}(${src(actual.right)})` | | comparator `<=` | `expect(${src(actual.left)}).${wantsTruth ? 'toBeLessThanOrEqual' : 'toBeGreaterThan'}(${src(actual.right)})` | | `instanceof` | `expect(${src(actual.left)}).${wantsTruth ? '' : 'not.'}toBeInstanceOf(${src(actual.right)})` | | `includes()` (trusted string receiver) | `expect(${src(actual.callee.object)}).${wantsTruth ? '' : 'not.'}toContain(${src(actual.arguments[0])})` | Examples: - `expect(a > b).toBe(false)` → `expect(a).toBeLessThanOrEqual(b)` - `expect(a > b).not.toBe(false)` → `expect(a).toBeGreaterThan(b)` ### 5.2 Chai BDD suggestions Do **not** rebuild the full node from scratch if the original source contains an optional Chai message argument. Rules: - For null / undefined, reuse the original chain prefix so `expect(x, 'msg')` is preserved: `expect(x, 'msg').to.equal(null)` → `expect(x, 'msg').to.be.null`. - For length / comparator / `instanceof` / `includes()`, rebuild the `expect(...)` subject but carry `base.arguments.slice(1)` through to the new `expect(...)` call. - For comparators, choose the direct Chai chainer from `(actual.operator, wantsTruth)`: `>` → `above` / `at.most`, `>=` → `at.least` / `below`, `<` → `below` / `at.least`, `<=` → `at.most` / `above`. - For `includes()`, only offer a suggestion when `isTrustedStringIncludesCall(...)` passes. - For `should` style there is no message argument; rebuild from `${src(subject)}.should`. - If the existing chain uses unsupported modifiers that cannot be preserved exactly, report the issue but omit the suggestion. Examples: - `expect(items.length, 'msg').to.equal(3)` → `expect(items, 'msg').to.have.lengthOf(3)` - `expect(text.includes(x), 'msg').to.equal(false)` → `expect(text, 'msg').to.not.include(x)` ### 5.3 Chai assert suggestions Preserve both the assert object and trailing message arguments: | Category | Suggestion template | |---|---| | null | `${src(assertObject)}.isNull(${src(actual)}${trailingArgs})` | | not null | `${src(assertObject)}.isNotNull(${src(actual)}${trailingArgs})` | | undefined | `${src(assertObject)}.isUndefined(${src(actual)}${trailingArgs})` | | defined | `${src(assertObject)}.isDefined(${src(actual)}${trailingArgs})` | | length | `${src(assertObject)}.lengthOf(${src(actual.object)}, ${src(expected)}${trailingArgs})` | | comparator `>` | `${src(assertObject)}.${wantsTruth ? 'isAbove' : 'isAtMost'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs})` | | comparator `>=` | `${src(assertObject)}.${wantsTruth ? 'isAtLeast' : 'isBelow'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs})` | | comparator `<` | `${src(assertObject)}.${wantsTruth ? 'isBelow' : 'isAtLeast'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs})` | | comparator `<=` | `${src(assertObject)}.${wantsTruth ? 'isAtMost' : 'isAbove'}(${src(actual.left)}, ${src(actual.right)}${trailingArgs})` | | instanceof (positive) | `${src(assertObject)}.instanceOf(${src(actual.left)}, ${src(actual.right)}${trailingArgs})` | | instanceof (negative) | `${src(assertObject)}.notInstanceOf(${src(actual.left)}, ${src(actual.right)}${trailingArgs})` | | `includes()` (positive, trusted string receiver) | `${src(assertObject)}.include(${src(actual.callee.object)}, ${src(actual.arguments[0])}${trailingArgs})` | | `includes()` (negative, trusted string receiver) | `${src(assertObject)}.notInclude(${src(actual.callee.object)}, ${src(actual.arguments[0])}${trailingArgs})` | Examples: - `a.strictEqual(value, null, 'msg')` → `a.isNull(value, 'msg')` - `assert.strictEqual(items.length, 3, 'msg')` → `assert.lengthOf(items, 3, 'msg')` - `assert.strictEqual(a > b, false, 'msg')` → `assert.isAtMost(a, b, 'msg')` Suggestion guard: - only offer a suggestion when the callee is a member expression on an assert object that can be rewritten directly; - only offer a `lengthOf(...)` suggestion for the positive semantic case; if the original assertion means “does not have this length”, report nothing because Chai assert has no dedicated negative `lengthOf` helper; - only offer containment suggestions when the `.includes()` receiver passes the trusted string guard; - if the source shape cannot preserve the assert target unambiguously, report without suggestion. ### 5.4 Cypress suggestions Only the null / undefined forms are in-scope for suggestions in v1. | Pattern | Suggestion | |---|---| | `should('equal', null)` | replace first string arg with `'be.null'` and remove the expected arg | | `should('deep.equal', null)` | replace first string arg with `'be.null'` and remove the expected arg | | `should('equal', undefined)` | replace first string arg with `'be.undefined'` and remove the expected arg | | `should('not.equal', undefined)` | replace first string arg with `'not.be.undefined'` and remove the expected arg | If the Cypress call has additional arguments beyond the supported shape, report without suggestion. ### 5.5 Playwright No suggestion in v1. Every Playwright locator rewrite changes both the awaited expression and the assertion subject. Covered issue-only patterns: - `expect(await locator.isVisible()).toBe(true/false)` and the other supported state getters - generic equality on `await locator.count()` (`toBe`, `toEqual`, `toStrictEqual`, with optional `.not`) - generic equality on `await locator.inputValue()` (`toBe`, `toEqual`, `toStrictEqual`, with optional `.not`) Do not mirror `.not.` blindly. Derive the desired boolean/value after applying the original negation first, then choose the dedicated locator assertion for that semantic target. Examples: - `expect(await locator.isVisible()).not.toBe(false)` → `await expect(locator).toBeVisible()` - `expect(await locator.isChecked()).toBe(false)` → `await expect(locator).not.toBeChecked()` - `expect(await locator.count()).not.toBe(3)` → `await expect(locator).not.toHaveCount(3)`
S5906 reports ESLint suggestions, so its generated and deployed JavaScript metadata must mark quickfix support as covered. The stale metadata left the rule without fixability metadata, causing generateMeta to reject the rule before the unit test could run. Keep the S5906 unit test expectations explicit about suggestion outputs and no-suggestion cases.
|
Aligned the implementation with the verified pattern tables: issue/quickfix messages now use the requested suggestion text, quick fixes are emitted where supported, Chai BDD/assert mappings preserve messages and assert aliases, comparator names match Chai/Jest/assert APIs, Cypress suggestions are limited to the safe null/undefined shapes, and Playwright locator reports cover the issue-only boolean/count/value cases. |
Split Chai and Cypress assertion extraction out of the shared assertion helper so the modified helper stays below the file-length limit. Avoid the forbidden non-null assertion in the S5906 suggestion fixer and simplify member-chain guards to avoid the optional-chain readability issue. No NOSONAR suppressions were added.
Rewrite the Playwright locator matcher selection to avoid a negated condition while preserving the same truthy/falsy assertion mapping. No suppressions were added.
Analyzed all 508 S5906 ruling entries from the all-rules ruling file and set every entry to shouldRaise=true. The current actual reports match those decisions, including the three p5.js Chai assert.strictEqual length checks that are actual-only reports. Fixed the previous unit-test regression in the shared assertion helper by recognizing Node assert.deepEqual and assert.notDeepEqual. Calls from node:assert stay loose comparisons, while calls from node:assert/strict are normalized as deep comparisons so S5845 reports incompatible static types without making S5914 flag loose deep-equality fresh-reference checks. Verified with focused S5845, S5906, and S5914 unit tests.
Add unit tests for S5906 assertion suggestion edge cases. This improves coverage for previously untested assertion helper branches.
|
There are two small duplication clusters here that look straightforward to factor out. First, Second, One detail to watch: I would avoid moving these helpers into |
Comment: There are two small duplication clusters here that look straightforward to factor out. First, `packages/analysis/src/jsts/rules/helpers/assertions-chai.ts` and `packages/analysis/src/jsts/rules/helpers/assertions-cypress.ts` both implement the same Chai property-name to predicate mapping (`true|ok|false|null|undefined|exist|exists`) and the same `getArgumentAtIndex(...)` helper. I would extract those shared primitives into a tiny leaf helper module, then keep only the framework-specific wrapping logic in each file. For example, the shared file could expose `getChaiPropertyPredicate(name)` and `getArgumentAtIndex(node, index)`. Second, `packages/analysis/src/jsts/rules/S5906/rule.ts` and `packages/analysis/src/jsts/rules/S5906/playwright-suggestions.ts` both carry the same `expect(...).not...` chain walker. Since that logic is only used inside S5906, I would move it into a small local helper such as `packages/analysis/src/jsts/rules/S5906/expect-chain.ts` (or `utils.ts`) with a single `getExpectChain(node): { actual, negated } | null` export and reuse it from both files. One detail to watch: I would avoid moving these helpers into `helpers/assertions.ts`, because `assertions.ts` already depends on the Chai/Cypress extractors. Putting the shared code there would risk introducing a dependency cycle. A dedicated leaf helper file keeps the dependency graph clean.
Import ChaiPredicate from the shared Chai assertion helper so assertions-chai.ts compiles when getChaiAssertPredicate annotates predicate results.
|




Adds rule S5906 to detect generic test assertions where a more specific matcher should be used.