Skip to content

feat(linter/unicorn): implement prefer-single-call rule#22857

Closed
bkmashiro wants to merge 0 commit into
oxc-project:mainfrom
bkmashiro:main
Closed

feat(linter/unicorn): implement prefer-single-call rule#22857
bkmashiro wants to merge 0 commit into
oxc-project:mainfrom
bkmashiro:main

Conversation

@bkmashiro

@bkmashiro bkmashiro commented May 30, 2026

Copy link
Copy Markdown
Contributor

Closes #684 (unicorn/prefer-single-call entry)

Summary

Implements the unicorn/prefer-single-call lint rule, which supersedes the deprecated unicorn/no-array-push-push.

  • Detects consecutive calls to the same variadic method on the same receiver and suggests merging them into a single call
  • Covers Array#push(), Element#classList.add(), Element#classList.remove(), and importScripts()
  • Provides an auto-fix that merges arguments, handles trailing commas, and preserves semicolons for ASI safety
  • Skips ignored push receivers (this.push, stream.push, process.stdin/stdout/stderr.push, this.stream.push)
  • Only merges when the receiver is a stable expression (identifier, this, or static member chain — not a function call result)

Test plan

  • Pass cases: different methods, different receivers, non-consecutive calls, optional chains, ignored patterns, unstable receivers, computed members
  • Fail/fix cases: basic push, multi-arg, trailing comma, ASI no-semi, spread args, classList.add/remove, importScripts, function body, nested member, this.prop.push, three consecutive calls
  • cargo test -p oxc_linter -- prefer_single_call passes

Copilot AI review requested due to automatic review settings May 30, 2026 22:52
@bkmashiro bkmashiro requested a review from camc314 as a code owner May 30, 2026 22:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds the unicorn/prefer-single-call lint rule with auto-fixes to merge consecutive variadic calls (e.g., push, classList.add/remove, importScripts) into a single call.

Changes:

  • Introduces PreferSingleCall rule implementation with diagnostics + fix merging consecutive calls.
  • Adds unit tests and a snapshot covering common and edge cases (ASI, trailing commas, spread args, nesting).
  • Wires the rule into the unicorn module plus generated enums/runners for registration.

Reviewed changes

Copilot reviewed 3 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
crates/oxc_linter/src/rules/unicorn/prefer_single_call.rs New rule implementation, fix logic, and tests for merging consecutive calls
crates/oxc_linter/src/snapshots/unicorn_prefer_single_call.snap Snapshot output for the new rule’s diagnostics
crates/oxc_linter/src/rules.rs Exposes the new unicorn rule module
crates/oxc_linter/src/generated/rules_enum.rs Registers the new rule in the generated rule enum and metadata
crates/oxc_linter/src/generated/rule_runner_impls.rs Adds RuleRunner implementation for the new rule

Comment on lines +194 to +207
// Locate the previous sibling statement in the parent block.
let parent = ctx.nodes().parent_node(node.id());
let stmts: &[Statement<'a>] = match parent.kind() {
AstKind::BlockStatement(b) => &b.body,
AstKind::Program(p) => &p.body,
AstKind::FunctionBody(b) => &b.statements,
AstKind::StaticBlock(b) => &b.body,
AstKind::SwitchCase(c) => &c.consequent,
_ => return,
};

let Some(idx) = stmts.iter().position(|s| s.span() == expr_stmt.span) else {
return;
};
Comment on lines +116 to +128
if !is_stable_receiver(&member.object) {
return None;
}
let callee_text = call.callee.span().source_text(src);
if PUSH_IGNORE.contains(&callee_text) {
return None;
}
let receiver_text = member.object.span().source_text(src);
Some(CallInfo {
description: "Array#push()",
receiver_text,
diagnostic_span: member.property.span,
})

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c7033ee66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +241 to +245
let args_text = second_args
.iter()
.map(|a| a.span().source_text(src))
.collect::<Vec<_>>()
.join(", ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not autofix state-dependent second arguments

When the second call's arguments read state changed by the first call, this autofix moves that evaluation before the first call executes. For example, arr.push(1); arr.push(arr.length); pushes the updated length in the original code, but the fix produces arr.push(1, arr.length);, where arr.length is evaluated before 1 is pushed. The rule should avoid offering a fix (or avoid reporting) unless the later arguments are proven independent of the first call's side effects.

Useful? React with 👍 / 👎.

Comment on lines +271 to +275
let gap_span = Span::new(first_stmt_end, second_stmt_end);
if !first_has_semi && second_has_semi {
fix.push(Fix::new(";", gap_span));
} else {
fix.push(Fix::delete(gap_span));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve comments between merged calls

This deletion span covers all trivia between the end of the first statement and the end of the second statement, so an autofix for foo.push(1); // keep this\nfoo.push(2); silently removes the intervening comment. Fixes should not drop user comments; either preserve comments in the replacement or withhold the fix when the gap contains comments.

Useful? React with 👍 / 👎.

@camc314 camc314 added the A-linter Area - Linter label May 31, 2026
@camc314 camc314 changed the title feat(linter): implement unicorn/prefer-single-call rule feat(linter/unicorn): implement prefer-single-call rule May 31, 2026

@camc314 camc314 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment about performance

Comment on lines +230 to +231
let Statement::ExpressionStatement(prev_es) = prev_stmt else { continue; };
let Statement::ExpressionStatement(curr_es) = curr_stmt else { continue; };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it's going to be cheaper (better performance) to instead match only expression stmts, then look at their parents, then siblings.

This code effectivly manually iterates over every single stmt in the program which will be slow, I think doing it the other way round should be cheaper

@camc314

camc314 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@bkmashiro i seem to have screwed up a rebase - do you mind making a new PR with a branch name different to main ? 😅

@codspeed-hq

codspeed-hq Bot commented Jun 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 5 untouched benchmarks
⏩ 61 skipped benchmarks1


Comparing bkmashiro:main (282f41a) with main (0f200a9)

Open in CodSpeed

Footnotes

  1. 61 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@bkmashiro

Copy link
Copy Markdown
Contributor Author

No worries! Opened a new PR from a non-main branch here: #23235

camc314 added a commit that referenced this pull request Jun 11, 2026
Closes #684 (unicorn/prefer-single-call entry)
Supersedes #22857 (reopened from a non-`main` branch after the old PR
branch got rebased incorrectly).

## Summary

Implements the `unicorn/prefer-single-call` lint rule, which supersedes
the deprecated `unicorn/no-array-push-push`.

- Detects consecutive calls to the same variadic method on the same
receiver and suggests merging them into a single call
- Covers `Array#push()`, `Element#classList.add()`,
`Element#classList.remove()`, and `importScripts()`
- Provides an auto-fix that merges arguments, handles trailing commas,
and preserves semicolons for ASI handling
- Skips ignored `push` receivers (`this.push`, `stream.push`,
`process.stdin/stdout/stderr.push`, `this.stream.push`)
- Only merges when the receiver is a stable expression (identifier,
`this`, or static member chain — not a function call result)
- Addresses prior review feedback by running from `ExpressionStatement`
and checking the parent statement list / previous sibling
- Updates the website linter schema snapshot and npm oxlint
configuration schema for the new rule

## Test plan

- [x] Pass cases: different methods, different receivers,
non-consecutive calls, optional chains, ignored patterns, unstable
receivers, computed members
- [x] Fail/fix cases: basic push, multi-arg, trailing comma, ASI
no-semi, spread args, classList.add/remove, importScripts, function
body, nested member, `this.prop.push`, three consecutive calls
- [x] `cargo fmt --check`
- [x] `cargo test -p oxc_linter -- prefer_single_call`
- [x] `cargo test -p website_linter --bin website_linter
json_schema::test_schema_json`
- [x] `git diff --check`

## AI usage disclosure

I used AI assistance while preparing and iterating on this PR, and I
reviewed the generated changes and test results before submission.

---------

Co-authored-by: Cameron Clark <cameron.clark@hey.com>
camc314 added a commit that referenced this pull request Jul 3, 2026
Closes #684 (unicorn/prefer-single-call entry)
Supersedes #22857 (reopened from a non-`main` branch after the old PR
branch got rebased incorrectly).

## Summary

Implements the `unicorn/prefer-single-call` lint rule, which supersedes
the deprecated `unicorn/no-array-push-push`.

- Detects consecutive calls to the same variadic method on the same
receiver and suggests merging them into a single call
- Covers `Array#push()`, `Element#classList.add()`,
`Element#classList.remove()`, and `importScripts()`
- Provides an auto-fix that merges arguments, handles trailing commas,
and preserves semicolons for ASI handling
- Skips ignored `push` receivers (`this.push`, `stream.push`,
`process.stdin/stdout/stderr.push`, `this.stream.push`)
- Only merges when the receiver is a stable expression (identifier,
`this`, or static member chain — not a function call result)
- Addresses prior review feedback by running from `ExpressionStatement`
and checking the parent statement list / previous sibling
- Updates the website linter schema snapshot and npm oxlint
configuration schema for the new rule

## Test plan

- [x] Pass cases: different methods, different receivers,
non-consecutive calls, optional chains, ignored patterns, unstable
receivers, computed members
- [x] Fail/fix cases: basic push, multi-arg, trailing comma, ASI
no-semi, spread args, classList.add/remove, importScripts, function
body, nested member, `this.prop.push`, three consecutive calls
- [x] `cargo fmt --check`
- [x] `cargo test -p oxc_linter -- prefer_single_call`
- [x] `cargo test -p website_linter --bin website_linter
json_schema::test_schema_json`
- [x] `git diff --check`

## AI usage disclosure

I used AI assistance while preparing and iterating on this PR, and I
reviewed the generated changes and test results before submission.

---------

Co-authored-by: Cameron Clark <cameron.clark@hey.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-linter Area - Linter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

☂️ eslint-plugin-unicorn

3 participants