Skip to content

JS-1687 Rule S5906 The most specific assertion should be used#7118

Open
sonar-nigel[bot] wants to merge 19 commits into
masterfrom
new-rule/JS-1687-S5906
Open

JS-1687 Rule S5906 The most specific assertion should be used#7118
sonar-nigel[bot] wants to merge 19 commits into
masterfrom
new-rule/JS-1687-S5906

Conversation

@sonar-nigel
Copy link
Copy Markdown
Contributor

@sonar-nigel sonar-nigel Bot commented May 28, 2026

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

  • Reports issue-only findings aligned with the RSPEC metadata.
  • Covers generic assertion patterns, including Playwright locator assertions.
  • Fixes the Playwright locator detection to verify candidate locator expressions are calls before applying method-call matching, preserving behavior while satisfying bridge TypeScript compile checks.

Sonar Vibe Bot added 3 commits May 28, 2026 07:30
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.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 28, 2026 08:18 Inactive
@nathsou nathsou self-assigned this May 28, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

Ruling Report

New issues flagged (508 issues)

S5906

console/src/utils/tests/valueparser.ts:29

    27 |       typeIdentifier: 'Int',
    28 |     })
>   29 |     expect(stringToValue('', field)).toBe(null)
    30 |   })
    31 | 

console/src/utils/tests/valueparser.ts:56

    54 |       typeIdentifier: 'Float',
    55 |     })
>   56 |     expect(stringToValue('', field)).toBe(null)
    57 |   })
    58 | 

console/src/utils/tests/valueparser.ts:83

    81 |       typeIdentifier: 'Float',
    82 |     })
>   83 |     expect(stringToValue('', field)).toBe(null)
    84 |   })
    85 | 

console/src/utils/tests/valueparser.ts:137

   135 |       typeIdentifier: 'Boolean',
   136 |     })
>  137 |     expect(stringToValue('', field)).toBe(null)
   138 |   })
   139 | 

desktop/app/test/unit/enum-test.ts:17

    15 | 
    16 |   it("returns undefined when enum value doesn't exist", () => {
>   17 |     expect(parseEnumValue(TestEnum, 'baz')).toBe(undefined)
    18 |   })
    19 | 

desktop/app/test/unit/enum-test.ts:31

    29 |     expect('foo' in child).toBeTrue()
    30 |     expect(child.foo).toBe('bar')
>   31 |     expect(parseEnumValue(child, 'bar')).toBe(undefined)
    32 |   })
    33 | })

desktop/app/test/unit/git-store-test.ts:187

   185 |       await gitStore.loadLocalCommits(tip.branch)
   186 | 
>  187 |       expect(gitStore.localCommitSHAs.length).toEqual(1)
   188 | 
   189 |       await gitStore.undoCommit(firstCommit!)

desktop/app/test/unit/git-store-test.ts:203

   201 |         repository.path
   202 |       )
>  203 |       expect(result.stdout.length).toEqual(0)
   204 |     })
   205 |   })

desktop/app/test/unit/git-store-test.ts:220

   218 |       let status = await getStatusOrThrow(repo!)
   219 |       let files = status.workingDirectory.files
>  220 |       expect(files.length).toEqual(1)
   221 | 
   222 |       await gitStore.discardChanges([files[0]])

desktop/app/test/unit/git-store-test.ts:226

   224 |       status = await getStatusOrThrow(repo)
   225 |       files = status.workingDirectory.files
>  226 |       expect(files.length).toEqual(0)
   227 |     })
   228 |   })

...and 498 more

📋 View full report

New issues flagged (508)

S5906

Sonar Vibe Bot added 2 commits May 28, 2026 13:25
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.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 28, 2026 13:26 Inactive
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.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 28, 2026 13:35 Inactive
@sonar-nigel sonar-nigel Bot marked this pull request as ready for review May 28, 2026 13:40
@sonar-nigel sonar-nigel Bot requested a review from a team May 28, 2026 13:40
@nathsou nathsou removed the request for review from a team May 28, 2026 13:45
Replace operator lookup arrays in the S5906 assertion suggestion helper with Sets and use .has() for membership checks, addressing S7776 without changing assertion suggestion behavior.

No NOSONAR suppressions were added.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 28, 2026 13:58 Inactive
Comment thread packages/analysis/src/jsts/rules/S5906/rule.ts
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` |
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 28, 2026 14:22 Inactive
@nathsou nathsou self-requested a review May 28, 2026 14:25
@nathsou
Copy link
Copy Markdown
Contributor

nathsou commented May 28, 2026

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:

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:

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)

Sonar Vibe Bot added 2 commits May 28, 2026 14:38
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.
@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel Bot commented May 28, 2026

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.

@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 28, 2026 14:48 Inactive
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.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 29, 2026 09:08 Inactive
Rewrite the Playwright locator matcher selection to avoid a negated
condition while preserving the same truthy/falsy assertion mapping.

No suppressions were added.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 29, 2026 10:24 Inactive


# Conflicts:
#	packages/analysis/src/jsts/rules/helpers/assertions.ts
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 29, 2026 13:41 Inactive
Sonar Vibe Bot added 2 commits May 29, 2026 14:32
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.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 29, 2026 14:33 Inactive
Add unit tests for S5906 assertion suggestion edge cases.
This improves coverage for previously untested assertion helper branches.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 29, 2026 15:12 Inactive
@nathsou
Copy link
Copy Markdown
Contributor

nathsou commented May 29, 2026

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.

Sonar Vibe Bot added 2 commits May 29, 2026 15:40
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.
@sonar-nigel sonar-nigel Bot temporarily deployed to sca-checking May 29, 2026 15:41 Inactive
@sonarqube-next
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant