JS-2409 Implement rule S9382: Promises should not be awaited sequentially in a loop by martin-strecker-sonarsource · Pull Request #7900 · SonarSource/SonarJS · GitHub
Skip to content

JS-2409 Implement rule S9382: Promises should not be awaited sequentially in a loop - #7900

Open
martin-strecker-sonarsource wants to merge 8 commits into
masterfrom
Martin/JS-2409_AwaitInLoop
Open

martin-strecker-sonarsource wants to merge 8 commits into
masterfrom
Martin/JS-2409_AwaitInLoop

Conversation

@martin-strecker-sonarsource

@martin-strecker-sonarsource martin-strecker-sonarsource commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes JS-2409.

Summary

  • New rule S9382 flags await used inside a loop body, which forces each iteration's async operation to run sequentially instead of in parallel (matches Biome's noAwaitInLoops, which is itself just a re-exposure of ESLint core's no-await-in-loop).
  • Implemented as a plain wrap of ESLint core's no-await-in-loop (external/core.ts), following the existing S1751/S1763/S878 pattern — no custom decorator/behavior change for now. That's a deliberate scope decision, not an oversight: see "Open design question" in JS-2409 about whether to add a Sonar-side decorator suppressing the early-exit false-positive case ESLint's own maintainers declined to fix (eslint#14700).
  • Not enabled in the default "Sonar way" profile (defaultQualityProfiles: []), matching ESLint's own recommended: false and Biome's off-by-default posture — this rule has a known high-noise profile on intentionally-sequential code (dependent iterations, retries, rate limiting, ordered writes), documented in the rule's Exceptions section.
  • quickfix: infeasible — rewriting to Promise.all() changes execution/error semantics, not a safe mechanical fix.

Dependency

  • RSPEC key S9382 reserved via SonarSource/rspec#8127 (still in review as of this PR). This PR includes a local mirror of that rule's metadata.json at sonar-plugin/javascript-checks/.../rules/javascript/S9382.json, since npm run generate-meta reads from this vendored copy rather than the live rspec repo. If the rspec content changes during review, this file needs to stay in sync.

Test plan

  • unit.test.ts covers the for-await-of exclusion plus for...of/classic for/while/do...while/for...in invalid cases.
  • Full npm run bridge:test suite: 2371/2388 passing; the 17 failures are all pre-existing, unrelated gRPC server port-binding flakiness in analyze-project-server.test.ts, not touched by this change.
  • npm run ruling run against the full third-party corpus; new findings spot-checked for correctness (e.g. vuetify/.../form.ts:89 sequential validation loop, sizzle/tasks/karma-tests.js:30 sequential subprocess spawning). Synced via npm run ruling-sync — 11 new expected-result files added, no existing baselines changed.

🤖 Generated with Claude Code

…ally in a loop

Decorates ESLint core's no-await-in-loop rule, following the existing
external-rule pattern (S1751/S1763/S878) rather than adding any Sonar-side
behavior change for now.
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 8, 2026

Copy link
Copy Markdown

Comment thread packages/analysis/src/jsts/rules/S9382/unit.test.ts
Pins rspec.sha to SonarSource/rspec#8127 (still unmerged) so CI's
prepare_rspec_rule_data job fetches the real RSPEC metadata for S9382
instead of failing with ENOENT on resources/rule-data/javascript/S9382.json,
per docs/BUILD.md's documented process for a SonarJS PR that depends on
an in-flight RSPEC change. Remove this file once SonarSource/rspec#8127
merges to the RSPEC default branch.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Ruling Report

New issues flagged (247 issues)

S9382

desktop/app/src/lib/api.ts:1372

  1370 |     let nextPath: string | null = urlWithQueryString(path, params)
  1371 |     do {
> 1372 |       const response: Response = await this.request('GET', nextPath)
  1373 |       if (opts.suppressErrors !== false && !response.ok) {
  1374 |         log.warn(`fetchAll: '${path}' returned a ${response.status}`)

desktop/app/src/lib/api.ts:1378

  1376 |       }
  1377 | 
> 1378 |       const items = await parsedResponse<ReadonlyArray<T>>(response)
  1379 |       if (items) {
  1380 |         buf.push(...items)

desktop/app/src/lib/ci-checks/ci-checks.ts:77

    75 |       }
    76 | 
>   77 |       const log = await stepLogFile.async('text')
    78 |       stepsWLogs.push({ ...step, log })
    79 |     }

desktop/app/src/lib/ci-checks/ci-checks.ts:376

   374 |     // We can prevent several job network calls by caching them.
   375 |     const workFlowRunJobs =
>  376 |       jobsCache.get(wfId) ?? (await api.fetchWorkflowRunJobs(owner, repo, wfId))
   377 |     jobsCache.set(wfId, workFlowRunJobs)
   378 | 

desktop/app/src/lib/ci-checks/ci-checks.ts:398

   396 |     // keep retrieving it. So we are hashing it.
   397 |     const logZip =
>  398 |       logCache.get(logs_url) ?? (await api.fetchWorkflowRunJobLogs(logs_url))
   399 |     if (logZip === null) {
   400 |       mappedCheckRuns.push(cr)

desktop/app/src/lib/ci-checks/ci-checks.ts:409

   407 |       ...cr,
   408 |       htmlUrl: matchingJob.html_url,
>  409 |       actionJobSteps: await parseJobStepLogs(logZip, matchingJob),
   410 |     })
   411 |   }

desktop/app/src/lib/ci-checks/ci-checks.ts:508

   506 |     const actionsWorkflow =
   507 |       cachedActionWorkFlow === undefined
>  508 |         ? await api.fetchPRActionWorkflowRunByCheckSuiteId(
   509 |             owner,
   510 |             repo,

desktop/app/src/lib/databases/repositories-database.ts:226

   224 | 
   225 |   for (const mapping of newOwnerIds) {
>  226 |     const modified = await ghReposTable
   227 |       .where('[ownerID+name]')
   228 |       .between([mapping.from], [mapping.from + 1])

desktop/app/src/lib/editors/darwin.ts:141

   139 |       // bundle isn't registered on the machine.
   140 |       // https://github.com/sindresorhus/app-path/blob/0e776d4e132676976b4a64e09b5e5a4c6e99fcba/index.js#L7-L13
>  141 |       const installPath = await appPath(identifier).catch(e =>
   142 |         e.message === "Couldn't find the app"
   143 |           ? Promise.resolve(null)

desktop/app/src/lib/editors/darwin.ts:151

   149 |       }
   150 | 
>  151 |       if (await pathExists(installPath)) {
   152 |         return installPath
   153 |       }

...and 237 more

📋 View full report

New issues flagged (247)

S9382

@datadog-sonarsource

This comment has been minimized.

@martin-strecker-sonarsource

martin-strecker-sonarsource commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Ruling results: 78 TP / 169 FP across 247 findings (32% TP rate)

Ran the full ruling corpus and manually classified every finding. Full breakdown in JS-2409.

The FP volume is expected — S9382 wraps ESLint core's no-await-in-loop verbatim, which is purely syntactic (any await lexically inside a loop, no data/control-flow analysis of whether iterations are actually independent). Most FPs are legitimate and not fixable by us: shared mutable state / lock safety (git index ops, XML stream writers), test/runtime isolation by design, retry/polling loops, and already-implemented rate-limited concurrency.

One category is different: ~14 of the 169 FPs (~8%) are "first-match-wins" early-exit searches — the loop's only await sits on a branch that unconditionally returns/breaks right after, so the loop runs at most once in practice and there's nothing to parallelize. This is the exact FP class ESLint's own maintainers declined to fix upstream (eslint#14700, closed as "working as intended"). It's the one class we could plausibly suppress ourselves via a decorator.

S9382 is currently not in the default Sonar Way profile (defaultQualityProfiles: []), given this FP profile.

Draft spec for a suppression decorator (unsettled in places — see below)

Both the await node and its enclosing loop are accessible at report time — ESLint's own context.report({ node: awaitNode, ... }) passes the AwaitExpression itself, .parent links are intact (confirmed via source, not assumed), and interceptReport passes the node through unmodified.

Sketch: walk forward from the await's containing statement; if every reachable path hits break/return/throw before the loop could iterate again, suppress.

exitsLoopOnEveryPath(position, loopNode):
  next = nextStatement(position)   // pop to parent's next statement when a block ends
  if next is "fell off the end of loopNode's body": return false
  match next.type:
    BreakStatement                  → targetsLoop(next, loopNode)
    ContinueStatement                → false
    ReturnStatement, ThrowStatement  → true
    BlockStatement                   → exitsLoopOnEveryPath(enter(next), loopNode)
    everything else (if/switch/nested-loop/plain statements) → [see open questions]

Two correctness knobs still open, both narrow edge cases:

  1. A later if/else where both arms break/return. Handling this needs recursing into both branches; skipping it (treating the if as opaque) just means we miss a rare additional suppression opportunity — safe, just less thorough.
  2. A later if/switch/nested-loop that itself contains a continue (e.g. await x(); if (cond) { continue; } break;). Fully skipping over these (not inspecting their contents at all) risks the opposite mistake — wrongly suppressing a real finding, since one branch could re-enter the loop. Bailing out conservatively on these avoids that risk but adds a bit more logic.

Nested loops (a break that only exits an inner loop while an outer loop can still re-trigger the same await) are out of scope entirely for v1 — deferred as a documented follow-up regardless of how (1) and (2) are resolved.

Given this is a non-Sonar-Way, opt-in rule — do we want to dig deeper into (1)/(2) here, or ship the passthrough as-is (matching S878/S1751/S1763 — no decorator, just the vanilla wrap) and revisit only if it comes up in practice?

@guillemsarda

…header cases

Per gitar-bot review: pins two behaviors that distinguish no-await-in-loop
from "any await under a loop" - an await inside a nested function/arrow
expression in the loop body (not reported, function nodes are traversal
boundaries), and an await in a for-await-of header's iterable expression
(not reported, since node.await === true makes the whole ForOfStatement a
boundary before the body/header distinction is even checked). Confirmed
both behaviors empirically by running against ESLint's actual rule.
Rebased the rspec branch onto latest rspec master (merged S7503's
javascript RSPEC data, added after this branch diverged) so CI's
prepare_rspec_rule_data job stops failing with ENOENT on S7503.json -
unrelated to S9382, caused by rspec.sha pinning a stale snapshot.
@sonarqube-next

sonarqube-next Bot commented Sep 8, 2026

Copy link
Copy Markdown

@guillemsarda guillemsarda 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.

Requesting changes until we decide if we cover the FP.

@@ -0,0 +1,119 @@
/*

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.

There's a known false positive that isn't mitigated: await immediately followed by a loop-terminating return/break still gets flagged, even though there's no real sequential-execution cost since the loop won't continue.

async function foo(arr) {
  for (const x of arr) {
    const r = await fetch(x);
    if (r.ok) return r; // loop ends right after — flagging this await is noisy
  }
}

This matches upstream eslint/eslint#14700, which was closed as "working as intended" since it's out of scope for the base no-await-in-loop rule. Since we're wrapping it for a Sonar-specific rule, could we add a decorator to suppress the report when the await is the last statement before a return/break that exits the loop? Otherwise this will be a live source of noise on early-exit-after-await code, which is a pretty common pattern.

@martin-strecker-sonarsource martin-strecker-sonarsource Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thinking through how to implement the suppression, I see three options:

  1. Only suppress when the await is unconditionally followed by return/break (no branching). This means the loop always exits after its first iteration — nonsense code, since it wouldn't be written as a loop.
  2. Suppress whenever a return/break appears anywhere later in the loop body, regardless of whether it's on the same path as the await, e.g.:
    for (const id of ids) {
      const item = await fetch(id);
      if (item.matches(query)) return item;
    }
    Easy to implement, but carries the potential for a high false-negative rate, since the presence of a later return/break doesn't tell us whether that path is actually taken on a given iteration.
  3. A heuristic somewhere in between 1 and 2
  4. Real reachability/CFG analysis to determine whether all paths after the await exit the loop. SonarJS doesn't currently have a control-flow-graph engine for JS/TS rules, so this would require building that piece first.

See also eslint/eslint#14700 where this FP was discussed, but no heuristic was ever really speced.

What did you have in mind? If it's 4), that's a separate SE-engine question we'd want to scope on its own rather than bundle into this rule's first cut.

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'd lean toward option 2. Even a return/break later in the loop body that isn't strictly on the await's execution path is a strong enough signal. That shape of code (search-and-early-exit) is exactly where sequential awaiting is idiomatic, not accidental. It shows the author already intended each promise to resolve one at a time before deciding whether to keep going, so I'd rather accept the occasional false negative than keep flagging that pattern as a bug. What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ran the actual numbers instead of guessing. Classified all 247 ruling findings by control-flow shape relative to the flagged await:

  • 8/247 (3.2%) are option-1 territory: every path after the await unconditionally exits the loop.
  • 30/247 (12.1%) are option-2-only: a return/break exists later in the loop body, but on a different path than the await's own.
  • 206/247 (83.4%) have no later return/break at all.

Option 2 would additionally suppress those 30. I classified each of the 30 for TP/FP against the independent-iteration test (shared state / dependency between iterations / retry-backoff / rate-limited / resource-bounded / inherently-sequential = FP; independent = TP): 14 TP / 16 FP.

So option 2 would introduce ~14 new false negatives (real, independent-iteration cases it would wrongly suppress) — things like for (const path of paths) { if (await pathExists(path)) return path; } in editors/darwin.ts/linux.ts/win32.ts, find-account.ts, dev_build_cache.ts — the "search a small list, return on first hit" idiom, occurring independently across several codebases in the corpus, not a one-off. It would also incidentally suppress 16 findings that were already FP for unrelated reasons (pagination, listener chains, retry loops, etc.) — those would go away regardless.

Does a ~14-in-247 (5.7% of all findings) false-negative rate seem like it's worth taking, against the noise reduction it buys?

@@ -0,0 +1,119 @@
/*
* SonarQube JavaScript Plugin

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.

Test coverage gap: unit.test.ts doesn't exercise a few cases that the underlying ESLint no-await-in-loop logic does handle:

  • await in the test/update clause of a classic for(;;) loop, e.g. for (let i = 0; await cond(i); i++) { ... }
  • await using declarations inside a loop, e.g. for (const x of arr) { await using r = acquire(); }
  • await nested inside intermediate control flow (e.g. if) within the loop body, to confirm detection still works through non-function nesting:
    async function foo(arr) {
      for (let i = 0; i < arr.length; i++) {
        if (arr[i]) {
          await bar(arr[i]);
        }
      }
    }

Could we add valid/invalid cases for these? Without them, a future ESLint upgrade or refactor of the decorator could silently regress any of these without any test catching it.

Wraps the ESLint core rule through interceptReport with a passthrough
report handler, so a suppression decorator can be added without
touching index.ts/meta.ts again. All existing unit tests stay green.
…uppress

Adds two invalid cases, still correctly flagged before the decorator
lands: the early-exit-after-await FP guillemsarda raised (await
followed by a loop-terminating return elsewhere in the body), and a
known accepted false negative it shares the same shape with
(independent per-item search-and-return-first-match).
Suppresses the report when a return/break appears later in the loop
body, on any path, regardless of whether it's the await's own path.
Agreed with guillemsarda (PR #7900 review) after quantifying the
trade-off against the ruling corpus: ~14/247 findings (5.7%) become
accepted false negatives (independent-iteration cases sharing the
same shape), against removing the early-exit FP class plus 16 other
findings that were already FP for unrelated reasons.

Flips the two pinned cases from the previous commit to valid.
Comment on lines +105 to +119
function hasLaterLoopExit(
loop: LoopLike,
afterNode: estree.Node,
visitorKeys: SourceCode.VisitorKeys,
): boolean {
const afterEnd = afterNode.range![1];

function search(node: estree.Node): boolean {
if (isBoundary(node)) {
return false;
}
if (
(node.type === 'ReturnStatement' || node.type === 'BreakStatement') &&
node.range![0] > afterEnd
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: Ruling baselines still contain issues the decorator now suppresses

The 11 expected-result files were generated in commit 4098098 against the undecorated no-await-in-loop, and this commit changes reported issues without regenerating them. desktop:app/src/lib/find-account.ts:102 is exactly the shape the decorator now suppresses (const canAccess = await canAccessRepository(...); if (canAccess) { return account } inside for (const account of sortedAccounts)), and the new unit test comment itself names find-account.ts and editors/darwin.ts as suppressed cases while desktop/typescript-S9382.json still expects lines 102 and 141/151/176. The ruling IT will fail on these projects until npm run ruling-sync is re-run after the decorator change and the removed entries are committed.

Regenerate the S9382 expected results so they reflect the decorated behavior:

# Re-run the ruling sync after the decorator change and commit the updated baselines:
#   npm run ruling && npm run ruling-sync
# Entries such as desktop:app/src/lib/find-account.ts:102 and
# desktop:app/src/lib/editors/darwin.ts:[141,151,176] are now suppressed and must be removed.
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +105 to +119
function hasLaterLoopExit(
loop: LoopLike,
afterNode: estree.Node,
visitorKeys: SourceCode.VisitorKeys,
): boolean {
const afterEnd = afterNode.range![1];

function search(node: estree.Node): boolean {
if (isBoundary(node)) {
return false;
}
if (
(node.type === 'ReturnStatement' || node.type === 'BreakStatement') &&
node.range![0] > afterEnd
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Bug: An unlabeled break of a switch suppresses the finding

hasLaterLoopExit matches any BreakStatement in the loop body positioned after the await, and only functions / for await are boundaries, so a plain switch after the await silences the rule even though its break exits the switch, not the loop: for (const x of xs) { await f(x); switch (x.k) { case 'a': break; } } is no longer reported. This shape is common in the corpus, so it is a broader false-negative class than the "break may target another loop" trade-off documented in the doc comment. Treat an unlabeled break whose nearest breakable ancestor is a SwitchStatement as not exiting the loop.

Track whether we are inside a switch (and not inside a nested loop) so an unlabeled switch break no longer counts as a loop exit:

function search(node: estree.Node, inSwitch = false): boolean {
  if (isBoundary(node)) {
    return false;
  }
  if (node.range![0] > afterEnd) {
    if (node.type === 'ReturnStatement') {
      return true;
    }
    if (node.type === 'BreakStatement' && (!inSwitch || node.label)) {
      return true;
    }
  }
  const nested = node.type === 'SwitchStatement' || (inSwitch && !isLoopLike(node));
  return childrenOf(node, visitorKeys).some(child => search(child, nested));
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +110 to +124
const afterEnd = afterNode.range![1];

function search(node: estree.Node): boolean {
if (isBoundary(node)) {
return false;
}
if (
(node.type === 'ReturnStatement' || node.type === 'BreakStatement') &&
node.range![0] > afterEnd
) {
return true;
}
return childrenOf(node, visitorKeys).some(search);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Header awaits are suppressed by any body exit, unlike do-while

For awaits in a loop header (while (await next()) { ... }, for (; await cond(); )) every return/break in the body has range[0] > afterEnd, so such loops are suppressed whenever the body contains any exit at all, regardless of position relative to the awaited call. The semantically equivalent do { ... if (x) break; } while (await next()) is still reported because the test comes lexically last, so two identical patterns get opposite verdicts. Consider restricting the later-exit search to awaits located inside loop.body (headers always re-evaluate per iteration) or comparing against the loop body start for header awaits.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown
CI failed: Build failure caused by a missing rule data file (S9380.json) during the rule data deployment step.

Overview

1 build failure encountered across 4 analyzed logs, caused by a missing rule data file during the deployment script execution.

Failures

Missing Rule Data File S9380.json (confidence: high)

  • Type: build
  • Affected jobs: 104443365621
  • Related to change: yes
  • Root cause: The deploy-rule-data.ts script failed because it tried to read resources/rule-data/javascript/S9380.json, which does not exist. This typically happens when the RSPEC sha pin or rule data is updated without including all required rule data JSON files.
  • Suggested fix: Add the missing rule data file resources/rule-data/javascript/S9380.json or update the RSPEC pin / deployment script to include all required rule data.

Summary

  • Change-related failures: 1 build failure due to missing rule data file S9380.json.
  • Infrastructure/flaky failures: None.
  • Recommended action: Ensure that all necessary rule data JSON files corresponding to the updated RSPEC pin are added to the repository.
Code Review ⚠️ Changes requested 2 resolved / 5 findings

Implements rule S9382 to flag await inside loops, wrapping ESLint's no-await-in-loop. The decorator that suppresses the early-exit false-positive was added in commit 710c4eb, but the 11 ruling baseline files were generated before that change and still contain issues the decorator now suppresses—regenerate with npm run ruling-sync after the decorator is finalized. Additionally, the decorator has two gaps: an unlabeled break in a switch statement incorrectly suppresses the finding even though it exits the switch, not the loop, and awaits in loop headers are suppressed by any body exit regardless of position, unlike do...while loops. Fix the decorator logic to handle these cases before merge.

⚠️ Bug: Ruling baselines still contain issues the decorator now suppresses

📄 packages/analysis/src/jsts/rules/S9382/decorator.ts:105-119 📄 packages/analysis/src/jsts/rules/S9382/unit.test.ts:75-86 📄 its/ruling/src/test/expected/desktop/typescript-S9382.json 📄 its/ruling/src/test/expected/vitest/typescript-S9382.json 📄 its/ruling/src/test/expected/fresh/typescript-S9382.json

The 11 expected-result files were generated in commit 4098098 against the undecorated no-await-in-loop, and this commit changes reported issues without regenerating them. desktop:app/src/lib/find-account.ts:102 is exactly the shape the decorator now suppresses (const canAccess = await canAccessRepository(...); if (canAccess) { return account } inside for (const account of sortedAccounts)), and the new unit test comment itself names find-account.ts and editors/darwin.ts as suppressed cases while desktop/typescript-S9382.json still expects lines 102 and 141/151/176. The ruling IT will fail on these projects until npm run ruling-sync is re-run after the decorator change and the removed entries are committed.

Regenerate the S9382 expected results so they reflect the decorated behavior
# Re-run the ruling sync after the decorator change and commit the updated baselines:
#   npm run ruling && npm run ruling-sync
# Entries such as desktop:app/src/lib/find-account.ts:102 and
# desktop:app/src/lib/editors/darwin.ts:[141,151,176] are now suppressed and must be removed.
💡 Bug: An unlabeled break of a switch suppresses the finding

📄 packages/analysis/src/jsts/rules/S9382/decorator.ts:105-119

hasLaterLoopExit matches any BreakStatement in the loop body positioned after the await, and only functions / for await are boundaries, so a plain switch after the await silences the rule even though its break exits the switch, not the loop: for (const x of xs) { await f(x); switch (x.k) { case 'a': break; } } is no longer reported. This shape is common in the corpus, so it is a broader false-negative class than the "break may target another loop" trade-off documented in the doc comment. Treat an unlabeled break whose nearest breakable ancestor is a SwitchStatement as not exiting the loop.

Track whether we are inside a switch (and not inside a nested loop) so an unlabeled switch break no longer counts as a loop exit
function search(node: estree.Node, inSwitch = false): boolean {
  if (isBoundary(node)) {
    return false;
  }
  if (node.range![0] > afterEnd) {
    if (node.type === 'ReturnStatement') {
      return true;
    }
    if (node.type === 'BreakStatement' && (!inSwitch || node.label)) {
      return true;
    }
  }
  const nested = node.type === 'SwitchStatement' || (inSwitch && !isLoopLike(node));
  return childrenOf(node, visitorKeys).some(child => search(child, nested));
}
💡 Edge Case: Header awaits are suppressed by any body exit, unlike do-while

📄 packages/analysis/src/jsts/rules/S9382/decorator.ts:110-124

For awaits in a loop header (while (await next()) { ... }, for (; await cond(); )) every return/break in the body has range[0] > afterEnd, so such loops are suppressed whenever the body contains any exit at all, regardless of position relative to the awaited call. The semantically equivalent do { ... if (x) break; } while (await next()) is still reported because the test comes lexically last, so two identical patterns get opposite verdicts. Consider restricting the later-exit search to awaits located inside loop.body (headers always re-evaluate per iteration) or comparing against the loop body start for header awaits.

✅ 2 resolved
Bug: No S9382 rule description: CheckListTest will fail until RSPEC merges

📄 sonar-plugin/javascript-checks/src/main/resources/org/sonar/l10n/javascript/rules/javascript/S9382.json:1-15 📄 packages/analysis/src/jsts/rules/S9382/meta.ts:17-19
CheckListTest.test() asserts that every key in CheckList.getAllChecks() has a /org/sonar/l10n/javascript/rules/javascript/<key>.html resource, and the Java check class for S9382 is generated automatically from packages/analysis/src/jsts/rules/S9382/. The .html files are gitignored and produced by npm run rspec:refresh from the rspec repo, so as long as SonarSource/rspec#8127 is unmerged there is no S9382.html, the plugin build fails with "No description for S9382", and a refresh run may also drop the hand-vendored S9382.json. The PR description only accounts for the vendored metadata.json; the rule cannot be merged before the rspec side lands (or the description must be provided some other way).

Quality: Unit tests omit the boundary cases that define this rule

📄 packages/analysis/src/jsts/rules/S9382/unit.test.ts:27-41
The valid/invalid cases only exercise the plain loop forms; the two behaviours that actually distinguish no-await-in-loop from "any await under a loop" are untested: an await inside a nested function/arrow expression declared in the loop body (not reported, since function nodes are traversal boundaries) and an await in a for await (... of ...) header expression. Adding those two cases pins the wrapped rule's contract so a future ESLint upgrade or a Sonar-side decorator (the open design question about the early-exit false positive) cannot silently change behaviour unnoticed.

🤖 Prompt for agents
Code Review: Implements rule S9382 to flag `await` inside loops, wrapping ESLint's `no-await-in-loop`. The decorator that suppresses the early-exit false-positive was added in commit 710c4eb, but the 11 ruling baseline files were generated before that change and still contain issues the decorator now suppresses—regenerate with `npm run ruling-sync` after the decorator is finalized. Additionally, the decorator has two gaps: an unlabeled `break` in a `switch` statement incorrectly suppresses the finding even though it exits the switch, not the loop, and awaits in loop headers are suppressed by any body exit regardless of position, unlike `do...while` loops. Fix the decorator logic to handle these cases before merge.

1. ⚠️ Bug: Ruling baselines still contain issues the decorator now suppresses
   Files: packages/analysis/src/jsts/rules/S9382/decorator.ts:105-119, packages/analysis/src/jsts/rules/S9382/unit.test.ts:75-86, its/ruling/src/test/expected/desktop/typescript-S9382.json, its/ruling/src/test/expected/vitest/typescript-S9382.json, its/ruling/src/test/expected/fresh/typescript-S9382.json

   The 11 expected-result files were generated in commit 4098098 against the undecorated `no-await-in-loop`, and this commit changes reported issues without regenerating them. `desktop:app/src/lib/find-account.ts:102` is exactly the shape the decorator now suppresses (`const canAccess = await canAccessRepository(...); if (canAccess) { return account }` inside `for (const account of sortedAccounts)`), and the new unit test comment itself names `find-account.ts` and `editors/darwin.ts` as suppressed cases while `desktop/typescript-S9382.json` still expects lines 102 and 141/151/176. The ruling IT will fail on these projects until `npm run ruling-sync` is re-run after the decorator change and the removed entries are committed.

   Fix (Regenerate the S9382 expected results so they reflect the decorated behavior):
   # Re-run the ruling sync after the decorator change and commit the updated baselines:
   #   npm run ruling && npm run ruling-sync
   # Entries such as desktop:app/src/lib/find-account.ts:102 and
   # desktop:app/src/lib/editors/darwin.ts:[141,151,176] are now suppressed and must be removed.

2. 💡 Bug: An unlabeled `break` of a `switch` suppresses the finding
   Files: packages/analysis/src/jsts/rules/S9382/decorator.ts:105-119

   `hasLaterLoopExit` matches any `BreakStatement` in the loop body positioned after the await, and only functions / `for await` are boundaries, so a plain `switch` after the await silences the rule even though its `break` exits the switch, not the loop: `for (const x of xs) { await f(x); switch (x.k) { case 'a': break; } }` is no longer reported. This shape is common in the corpus, so it is a broader false-negative class than the "break may target another loop" trade-off documented in the doc comment. Treat an unlabeled `break` whose nearest breakable ancestor is a `SwitchStatement` as not exiting the loop.

   Fix (Track whether we are inside a switch (and not inside a nested loop) so an unlabeled switch break no longer counts as a loop exit):
   function search(node: estree.Node, inSwitch = false): boolean {
     if (isBoundary(node)) {
       return false;
     }
     if (node.range![0] > afterEnd) {
       if (node.type === 'ReturnStatement') {
         return true;
       }
       if (node.type === 'BreakStatement' && (!inSwitch || node.label)) {
         return true;
       }
     }
     const nested = node.type === 'SwitchStatement' || (inSwitch && !isLoopLike(node));
     return childrenOf(node, visitorKeys).some(child => search(child, nested));
   }

3. 💡 Edge Case: Header awaits are suppressed by any body exit, unlike do-while
   Files: packages/analysis/src/jsts/rules/S9382/decorator.ts:110-124

   For awaits in a loop header (`while (await next()) { ... }`, `for (; await cond(); )`) every `return`/`break` in the body has `range[0] > afterEnd`, so such loops are suppressed whenever the body contains any exit at all, regardless of position relative to the awaited call. The semantically equivalent `do { ... if (x) break; } while (await next())` is still reported because the test comes lexically last, so two identical patterns get opposite verdicts. Consider restricting the later-exit search to awaits located inside `loop.body` (headers always re-evaluate per iteration) or comparing against the loop body start for header awaits.

Review coverage

Functional validation 1 of 1 objectives covered

Rules No rules evaluated

Auto-approval Not enabled · Set up

Implementation Status ✅ 1 of 1 objectives covered
JS-2409 - 1 of 1 objectives covered

This PR implements rule S9382 to detect and flag promises awaited sequentially in a loop by wrapping the ESLint core no-await-in-loop rule with an early-exit suppression decorator.

✅ 1 covered here
  • ✅ Implement rule S9382 to flag promises awaited sequentially in a loop

Tip

Comment Gitar fix CI or enable auto-apply: gitar auto-apply:on

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Counting what did not apply, without listing it.
Unblock → Override a blocking verdict and allow merging.

Comment with these commands to change the behavior for this request:

Auto-apply Compact Unblock
gitar auto-apply:on         
gitar display:verbose         
gitar unblock         

Was this helpful? React with 👍 / 👎 | Gitar

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.

2 participants