feat(linter): add no-promise-in-callback (#7307)

related: #4655

This PR implements a rule to detect Promises inside error-first
callbacks, preventing the mixed usage of callbacks and Promises.

Example of problematic code:
```javascript
a(function(err) { doThing().then(a) });
                  ^^^^^^^^^^^^^^
```


[Original
implementation](266ddbb030/rules/no-promise-in-callback.js)
This commit is contained in:
no-yan 2024-11-22 11:21:26 +09:00 committed by GitHub
parent 9002e97e12
commit 4ad26b952f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 250 additions and 0 deletions

View file

@ -485,6 +485,7 @@ mod promise {
pub mod catch_or_return;
pub mod no_callback_in_promise;
pub mod no_new_statics;
pub mod no_promise_in_callback;
pub mod no_return_in_finally;
pub mod param_names;
pub mod prefer_await_to_callbacks;
@ -784,6 +785,7 @@ oxc_macros::declare_all_lint_rules! {
oxc::uninvoked_array_callback,
promise::avoid_new,
promise::catch_or_return,
promise::no_promise_in_callback,
promise::no_callback_in_promise,
promise::no_new_statics,
promise::no_return_in_finally,

View file

@ -0,0 +1,168 @@
use oxc_ast::ast::FormalParameters;
use oxc_ast::AstKind;
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use crate::utils::is_promise;
use crate::{context::LintContext, rule::Rule, AstNode};
fn no_promise_in_callback_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("Avoid using promises inside of callbacks.")
.with_help("Use either promises or callbacks exclusively for handling asynchronous code.")
.with_label(span)
}
#[derive(Debug, Default, Clone)]
pub struct NoPromiseInCallback;
declare_oxc_lint!(
/// ### What it does
/// Disallows the use of Promises within error-first callback functions.
///
/// ### Why is this bad?
/// Mixing Promises and callbacks can lead to unclear and inconsistent code.
/// Promises and callbacks are different patterns for handling asynchronous code.
/// Mixing them makes the logic flow harder to follow and complicates error handling,
/// as callbacks rely on an error-first pattern, while Promises use `catch`.
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```js
/// doSomething((err, val) => {
/// if (err) console.error(err)
/// else doSomethingElse(val).then(console.log)
/// })
/// ```
///
/// Examples of **correct** code for this rule:
/// ```js
/// promisify(doSomething)()
/// .then(doSomethingElse)
/// .then(console.log)
/// .catch(console.error)
/// ```
NoPromiseInCallback,
suspicious,
);
impl Rule for NoPromiseInCallback {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
let AstKind::CallExpression(call_expr) = node.kind() else {
return;
};
if is_promise(call_expr).is_none() {
return;
}
// When a Promise is returned in a ReturnStatement, the function is most likely
// being used as part of a Promise chain rather than as a callback function.
// To avoid false positives, this case is intentionally excluded from the scope of this rule.
if let Some(AstKind::ReturnStatement(_)) = ctx.nodes().parent_kind(node.id()) {
return;
};
let mut ancestors = ctx.nodes().ancestors(node.id());
if ancestors.any(|node| is_callback_function(node, ctx)) {
ctx.diagnostic(no_promise_in_callback_diagnostic(call_expr.callee.span()));
}
}
}
fn is_callback_function<'a>(node: &AstNode<'a>, ctx: &LintContext<'a>) -> bool {
if !node.kind().is_function_like() {
return false;
}
if is_within_promise_handler(node, ctx) {
return false;
}
match node.kind() {
AstKind::Function(function) => is_error_first_callback(&function.params),
AstKind::ArrowFunctionExpression(arrow_function) => {
is_error_first_callback(&arrow_function.params)
}
_ => false,
}
}
fn is_error_first_callback(params: &FormalParameters) -> bool {
let Some(first_parameter) = params.items.first() else {
return false;
};
let Some(ident) = first_parameter.pattern.get_binding_identifier() else {
return false;
};
matches!(ident.name.as_str(), "err" | "error")
}
fn is_within_promise_handler<'a>(node: &AstNode<'a>, ctx: &LintContext<'a>) -> bool {
if !matches!(node.kind(), AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)) {
return false;
}
let Some(parent) = ctx.nodes().parent_node(node.id()) else {
return false;
};
if !matches!(ctx.nodes().kind(parent.id()), AstKind::Argument(_)) {
return false;
};
let Some(AstKind::CallExpression(call_expr)) = ctx.nodes().parent_kind(parent.id()) else {
return false;
};
matches!(call_expr.callee_name(), Some("then" | "catch"))
}
#[test]
fn test() {
use crate::tester::Tester;
// The following test cases are based on the original
// implementation from eslint-plugin-promise and are licensed under the ISC License.
//
// Copyright (c) 2020, Jamund Ferguson
// https://github.com/eslint-community/eslint-plugin-promise/blob/266ddbb03076c05c362a6daecb9382b80cdd7108/__tests__/no-promise-in-callback.js
let pass = vec![
"go(function() { return Promise.resolve(4) })",
"go(function() { return a.then(b) })",
"go(function() { b.catch(c) })",
"go(function() { b.then(c, d) })",
"go(() => Promise.resolve(4))",
"go((errrr) => a.then(b))",
"go((helpers) => { b.catch(c) })",
"go((e) => { b.then(c, d) })",
"a.catch((err) => { b.then(c, d) })",
"var x = function() { return Promise.resolve(4) }",
"function y() { return Promise.resolve(4) }",
"function then() { return Promise.reject() }",
"doThing(function(x) { return Promise.reject(x) })",
"doThing().then(function() { return Promise.all([a,b,c]) })",
"doThing().then(function() { return Promise.resolve(4) })",
"doThing().then(() => Promise.resolve(4))",
"doThing().then(() => Promise.all([a]))",
"a(function(err) { return doThing().then(a) })",
];
let fail = vec![
"a(function(err) { doThing().then(a) })",
"a(function(error, zup, supa) { doThing().then(a) })",
"a(function(error) { doThing().then(a) })",
"a((error) => { doThing().then(a) })",
"a((error) => doThing().then(a))",
"a((err, data) => { doThing().then(a) })",
"a((err, data) => doThing().then(a))",
"function x(err) { Promise.all() }",
"function x(err) { Promise.allSettled() }",
"function x(err) { Promise.any() }",
"let x = (err) => doThingWith(err).then(a)",
];
Tester::new(NoPromiseInCallback::NAME, pass, fail).test_and_snapshot();
}

View file

@ -0,0 +1,80 @@
---
source: crates/oxc_linter/src/tester.rs
snapshot_kind: text
---
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:19]
1 │ a(function(err) { doThing().then(a) })
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:32]
1 │ a(function(error, zup, supa) { doThing().then(a) })
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:21]
1 │ a(function(error) { doThing().then(a) })
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:16]
1 │ a((error) => { doThing().then(a) })
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:14]
1 │ a((error) => doThing().then(a))
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:20]
1 │ a((err, data) => { doThing().then(a) })
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:18]
1 │ a((err, data) => doThing().then(a))
· ──────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:19]
1 │ function x(err) { Promise.all() }
· ───────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:19]
1 │ function x(err) { Promise.allSettled() }
· ──────────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:19]
1 │ function x(err) { Promise.any() }
· ───────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.
⚠ eslint-plugin-promise(no-promise-in-callback): Avoid using promises inside of callbacks.
╭─[no_promise_in_callback.tsx:1:18]
1 │ let x = (err) => doThingWith(err).then(a)
· ─────────────────────
╰────
help: Use either promises or callbacks exclusively for handling asynchronous code.