feat(linter/unicorn): implement prefer-single-call rule#22857
Conversation
There was a problem hiding this comment.
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
PreferSingleCallrule 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 |
| // 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; | ||
| }; |
| 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, | ||
| }) |
There was a problem hiding this comment.
💡 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".
| let args_text = second_args | ||
| .iter() | ||
| .map(|a| a.span().source_text(src)) | ||
| .collect::<Vec<_>>() | ||
| .join(", "); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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)); |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
see comment about performance
| let Statement::ExpressionStatement(prev_es) = prev_stmt else { continue; }; | ||
| let Statement::ExpressionStatement(curr_es) = curr_stmt else { continue; }; |
There was a problem hiding this comment.
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
|
@bkmashiro i seem to have screwed up a rebase - do you mind making a new PR with a branch name different to |
Merging this PR will not alter performance
Comparing Footnotes
|
|
No worries! Opened a new PR from a non-main branch here: #23235 |
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>
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>
Closes #684 (unicorn/prefer-single-call entry)
Summary
Implements the
unicorn/prefer-single-calllint rule, which supersedes the deprecatedunicorn/no-array-push-push.Array#push(),Element#classList.add(),Element#classList.remove(), andimportScripts()pushreceivers (this.push,stream.push,process.stdin/stdout/stderr.push,this.stream.push)this, or static member chain — not a function call result)Test plan
this.prop.push, three consecutive callscargo test -p oxc_linter -- prefer_single_callpasses