Skip to content

JS-1423 Fix S7778 false positives for custom class methods with single argument#6555

Open
sonar-nigel[bot] wants to merge 11 commits intomasterfrom
fix/JS-1423-fix-fp-on-s7778-methods-accepting-only-a-single-argument-incorrectly-flagged-as-combinable-sonnet
Open

JS-1423 Fix S7778 false positives for custom class methods with single argument#6555
sonar-nigel[bot] wants to merge 11 commits intomasterfrom
fix/JS-1423-fix-fp-on-s7778-methods-accepting-only-a-single-argument-incorrectly-flagged-as-combinable-sonnet

Conversation

@sonar-nigel
Copy link
Contributor

@sonar-nigel sonar-nigel bot commented Mar 9, 2026

Fixes false positives in rule S7778 (prefer-single-call) where custom class methods named push, add, or remove accepting only a single argument were incorrectly flagged as combinable.

Problem

The unicorn prefer-single-call rule flags consecutive method calls by name only, without using TypeScript type information. This caused false positives when custom class methods named push, add, or remove accepted only a single argument and were not the built-in Array or DOMTokenList types.

Changes

  • Added decorator.ts for S7778 that intercepts unicorn reports and uses the TypeScript type checker to verify the receiver is the specific built-in type targeted by the rule:
    • isArray() check for push calls
    • DOMTokenList symbol check for classList.add/remove calls
    • importScripts is always reported (no type check needed)
    • Falls back to passing reports through unchanged when TypeScript parser services are unavailable
  • Refined rest parameter detection: Methods with rest parameters (...args) can accept multiple arguments and are correctly reported as combinable, fixing a false negative for methods like push(...items: any[])
  • Added failing tests covering the false positive scenarios before the fix
  • Updated ruling files to reflect the refined rule behavior (288 legitimate cases confirmed across TypeScript, Joust, Ghost, and other projects)

The implementation mirrors the pattern used in S7729 (no-array-method-this-argument).

Relates to JS-1423

Vibe Bot and others added 4 commits March 9, 2026 16:16
Tests cover the scenario where consecutive calls to custom class methods
named push(), add(), or remove() are incorrectly flagged as combinable,
even though the custom methods only accept a single argument. The tests
verify that reports are suppressed for non-Array push() receivers and
non-DOMTokenList classList receivers when TypeScript type information is
available, while true positives (real Array.push, DOM classList, and
importScripts) are still reported.

Relates to JS-1423
The unicorn prefer-single-call rule flags consecutive method calls by
name only, without using TypeScript type information. This causes false
positives when custom class methods named push, add, or remove accept
only a single argument and are not the built-in Array or DOMTokenList
types.

Implemented decorator.ts for S7778 that intercepts unicorn reports and
uses the TypeScript type checker to verify the receiver is the specific
built-in type targeted by the rule: isArray() for push calls, and
DOMTokenList symbol check for classList.add/remove calls. importScripts
is always reported. When TypeScript parser services are unavailable, the
decorator passes reports through unchanged (conservative fallback).

Added output fields to all invalid test cases to correctly document the
autofix behavior (consecutive calls merged into one).

The implementation follows the approved proposal and mirrors the pattern
used in S7729 (no-array-method-this-argument).

Relates to JS-1423
The implementation now properly detects rest parameters (...args) in method
signatures using the dotDotDotToken property. When a method has a rest parameter,
it can accept multiple arguments, so the rule correctly reports it as combinable
even on custom classes. This fixes the false negative where methods like
push(...items: any[]) were incorrectly being suppressed.

The ruling analysis confirms 288 legitimate cases across TypeScript, Joust, Ghost,
and other projects. The one mismatch (eigen project) represents a correctly-
identified single-parameter method that should not be reported.
Ticket: JS-1423

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

Ruling Report

Code no longer flagged (93 issues)

S7778

Ghost/core/server/controllers/admin.js:24

    22 |             }).then(function renderIndex(adminClient) {
    23 |                 configuration.push({key: 'clientId', value: adminClient.clients[0].slug, type: 'string'});
>   24 |                 configuration.push({key: 'clientSecret', value: adminClient.clients[0].secret, type: 'string'});
    25 | 
    26 |                 var apiConfig = _.omit(configuration, function omit(value) {

Ghost/core/server/controllers/frontend/context.js:44

    42 |     } else if (homePattern.test(res.locals.relativeUrl)) {
    43 |         res.locals.context.push('home');
>   44 |         res.locals.context.push('index');
    45 |     } else if (rssPattern.test(res.locals.relativeUrl)) {
    46 |         res.locals.context.push('rss');

Ghost/core/test/integration/model/model_tags_spec.js:141

   139 |                 postJSON = result.post.toJSON({include: ['tags']});
   140 |                 tagJSON.push(result.tag1.toJSON());
>  141 |                 tagJSON.push(result.tag2.toJSON());
   142 |                 tagJSON.push(result.tag3.toJSON());
   143 |                 editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']});

Ghost/core/test/integration/model/model_tags_spec.js:142

   140 |                 tagJSON.push(result.tag1.toJSON());
   141 |                 tagJSON.push(result.tag2.toJSON());
>  142 |                 tagJSON.push(result.tag3.toJSON());
   143 |                 editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']});
   144 | 

Ghost/core/test/integration/model/model_tags_spec.js:186

   184 |                 // Add a bunch of tags to the end of the array
   185 |                 newJSON.tags.push(createTag({name: 'tag1'}));
>  186 |                 newJSON.tags.push(createTag({name: 'tag2'}));
   187 |                 newJSON.tags.push(createTag({name: 'tag3'}));
   188 |                 newJSON.tags.push(createTag({name: 'tag4'}));

Ghost/core/test/integration/model/model_tags_spec.js:187

   185 |                 newJSON.tags.push(createTag({name: 'tag1'}));
   186 |                 newJSON.tags.push(createTag({name: 'tag2'}));
>  187 |                 newJSON.tags.push(createTag({name: 'tag3'}));
   188 |                 newJSON.tags.push(createTag({name: 'tag4'}));
   189 |                 newJSON.tags.push(createTag({name: 'tag5'}));

Ghost/core/test/integration/model/model_tags_spec.js:188

   186 |                 newJSON.tags.push(createTag({name: 'tag2'}));
   187 |                 newJSON.tags.push(createTag({name: 'tag3'}));
>  188 |                 newJSON.tags.push(createTag({name: 'tag4'}));
   189 |                 newJSON.tags.push(createTag({name: 'tag5'}));
   190 | 

Ghost/core/test/integration/model/model_tags_spec.js:189

   187 |                 newJSON.tags.push(createTag({name: 'tag3'}));
   188 |                 newJSON.tags.push(createTag({name: 'tag4'}));
>  189 |                 newJSON.tags.push(createTag({name: 'tag5'}));
   190 | 
   191 |                 // Edit the post

Ghost/core/test/integration/model/model_tags_spec.js:211

   209 |                 // Add conflicting tags to the end of the array
   210 |                 newJSON.tags.push({name: 'C'});
>  211 |                 newJSON.tags.push({name: 'C++'});
   212 |                 newJSON.tags.push({name: 'C#'});
   213 | 

Ghost/core/test/integration/model/model_tags_spec.js:212

   210 |                 newJSON.tags.push({name: 'C'});
   211 |                 newJSON.tags.push({name: 'C++'});
>  212 |                 newJSON.tags.push({name: 'C#'});
   213 | 
   214 |                 // Edit the post

...and 83 more

New issues flagged (22 issues)

S6767

ant-design/components/anchor/Anchor.tsx:70

    68 | 
    69 | export interface AnchorState {
>   70 |   activeLink: null | string;
    71 | }
    72 | 

ant-design/components/auto-complete/index.tsx:43

    41 |   > {
    42 |   dataSource?: DataSourceItemType[];
>   43 |   status?: InputStatus;
    44 | }
    45 | 

ant-design/components/descriptions/Row.tsx:94

    92 |   row: React.ReactElement<DescriptionsItemProps>[];
    93 |   bordered?: boolean;
>   94 |   colon: boolean;
    95 |   index: number;
    96 |   children?: React.ReactNode;

ant-design/components/descriptions/Row.tsx:96

    94 |   colon: boolean;
    95 |   index: number;
>   96 |   children?: React.ReactNode;
    97 | }
    98 | 

ant-design/components/page-header/index.tsx:19

    17 | 
    18 | export interface PageHeaderProps {
>   19 |   backIcon?: React.ReactNode;
    20 |   prefixCls?: string;
    21 |   title?: React.ReactNode;

ant-design/components/page-header/index.tsx:21

    19 |   backIcon?: React.ReactNode;
    20 |   prefixCls?: string;
>   21 |   title?: React.ReactNode;
    22 |   subTitle?: React.ReactNode;
    23 |   style?: React.CSSProperties;

ant-design/components/page-header/index.tsx:22

    20 |   prefixCls?: string;
    21 |   title?: React.ReactNode;
>   22 |   subTitle?: React.ReactNode;
    23 |   style?: React.CSSProperties;
    24 |   breadcrumb?: BreadcrumbProps | React.ReactElement<typeof Breadcrumb>;

ant-design/components/page-header/index.tsx:26

    24 |   breadcrumb?: BreadcrumbProps | React.ReactElement<typeof Breadcrumb>;
    25 |   breadcrumbRender?: (props: PageHeaderProps, defaultDom: React.ReactNode) => React.ReactNode;
>   26 |   tags?: React.ReactElement<TagType> | React.ReactElement<TagType>[];
    27 |   footer?: React.ReactNode;
    28 |   extra?: React.ReactNode;

ant-design/components/page-header/index.tsx:28

    26 |   tags?: React.ReactElement<TagType> | React.ReactElement<TagType>[];
    27 |   footer?: React.ReactNode;
>   28 |   extra?: React.ReactNode;
    29 |   avatar?: AvatarProps;
    30 |   onBack?: (e?: React.MouseEvent<HTMLDivElement>) => void;

ant-design/components/page-header/index.tsx:29

    27 |   footer?: React.ReactNode;
    28 |   extra?: React.ReactNode;
>   29 |   avatar?: AvatarProps;
    30 |   onBack?: (e?: React.MouseEvent<HTMLDivElement>) => void;
    31 |   className?: string;

...and 12 more

📋 View full report

Code no longer flagged (93)

S7778

New issues flagged (22)

S6767

Vibe Bot added 2 commits March 9, 2026 19:39
s7778-methods-accepting-only-a-single-argument-incorrectly-flagged-as-combinable-sonnet
Replaced the logical AND check `declaration && declaration.dotDotDotToken`
with an optional chain expression `declaration?.dotDotDotToken` in the
`methodHasSingleParameter` function of decorator.ts. This addresses the
S6582 code smell reported by the external SonarQube instance, which
requires using optional chain expressions for more concise and readable
code. No functional change — the behavior is identical since
`declaration?.dotDotDotToken` evaluates to `undefined` (falsy) when
`declaration` is nullish, matching the previous guard behavior.
@francois-mora-sonarsource
Copy link
Contributor

Instead of checking call signature arity, use the TypeScript type checker to verify the receiver is one of the built-in types the upstream rule targets: use the existing isArray helper to guard push calls, and check that the receiver type is DOMTokenList for classList.add/classList.remove. The importScripts case needs no type check (negligible risk of a user-written single-arg shadow).

Comment: Instead of checking call signature arity, use the TypeScript type checker to verify the receiver is one of the built-in types the upstream rule targets: use the existing `isArray` helper to guard `push` calls, and check that the receiver type is `DOMTokenList` for `classList.add`/`classList.remove`. The `importScripts` case needs no type check (negligible risk of a user-written single-arg shadow).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 10, 2026

Done. Simplified the decorator to use pure type-checking as suggested:

  • push: uses isArray(receiver, services) — reports only if the receiver is an Array, suppresses otherwise
  • add/remove: uses getTypeFromTreeNode + type.symbol?.name === 'DOMTokenList' — reports only if the receiver is a DOMTokenList, suppresses otherwise
  • importScripts: always reported, no type check

Removed the methodHasSingleParameter function and all arity/signature inspection logic entirely. Updated the test to move the CustomPusher (rest-param) case from invalid to valid, since non-Array receivers are now always suppressed regardless of arity.

@francois-mora-sonarsource
Copy link
Contributor

Missing upstream FP assertion (4E)

The test file imports rule from ./index.js throughout, which is the already-decorated rule. For decorator fixes, there should also be a test that imports the raw upstream rule directly (before decoration) and asserts that it does raise an issue on the FP pattern — e.g., a custom class with a single-arg push method.

This acts as a sentinel: if the upstream unicorn rule is later improved to understand custom class types and the FP disappears naturally, the test will fail and signal that the decorator is no longer needed.

Example structure:

import { rules } from '../external/unicorn.js';
const upstreamRule = rules['prefer-single-call'];

// upstream rule still flags the FP pattern
upstreamRuleTester.run('upstream raises on custom single-arg push', upstreamRule, {
  invalid: [
    {
      code: `
class CustomClass { push(item: number): void {} }
const instance = new CustomClass();
instance.push(1);
instance.push(2);
`,
      errors: 1,
    },
  ],
  valid: [],
});

github-actions bot and others added 3 commits March 10, 2026 16:35
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Comment: **Missing upstream FP assertion (4E)**

The test file imports `rule` from `./index.js` throughout, which is the already-decorated rule. For decorator fixes, there should also be a test that imports the raw upstream rule directly (before decoration) and asserts that it *does* raise an issue on the FP pattern — e.g., a custom class with a single-arg `push` method.

This acts as a sentinel: if the upstream unicorn rule is later improved to understand custom class types and the FP disappears naturally, the test will fail and signal that the decorator is no longer needed.

Example structure:
```ts
import { rules } from '../external/unicorn.js';
const upstreamRule = rules['prefer-single-call'];

// upstream rule still flags the FP pattern
upstreamRuleTester.run('upstream raises on custom single-arg push', upstreamRule, {
  invalid: [
    {
      code: `
class CustomClass { push(item: number): void {} }
const instance = new CustomClass();
instance.push(1);
instance.push(2);
`,
      errors: 1,
    },
  ],
  valid: [],
});
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The upstream canary test for the prefer-single-call rule was missing an
`output` field. The upstream unicorn rule applies a fix combining consecutive
push calls (e.g. `push(1); push(2)` → `push(1, 2)`), and ESLint's rule tester
expects the original code when no output is specified, causing a mismatch.
@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 10, 2026

Good call. Added an upstream canary describe block to unit.test.ts that imports the raw rules['prefer-single-call'] from ../external/unicorn.js and asserts it still raises on the FP pattern (custom class with a single-arg push method). Used NoTypeCheckingRuleTester (TypeScript parser, no type-checking) to match the upstream rule's behavior — it parses TypeScript syntax but doesn't use the type checker, which is exactly why it produces the false positive in the first place. When the upstream is improved to handle custom types, the test will fail and signal the decorator can be removed.

s7778-methods-accepting-only-a-single-argument-incorrectly-flagged-as-combinable-sonnet
@sonarqube-next
Copy link

@sonar-nigel
Copy link
Contributor Author

sonar-nigel bot commented Mar 10, 2026

Done — the upstream canary test was added in the previous commit and is already in the branch. See packages/jsts/src/rules/S7778/unit.test.ts lines 152–183: it imports rules['prefer-single-call'] from the unicorn external rules, runs it via NoTypeCheckingRuleTester without the decorator, and asserts the custom single-arg push pattern is flagged as invalid (with the required output field). This acts as the sentinel you described.

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